/* ========================================================================
* 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.Threading;
using System.Threading.Tasks;
using Opc.Ua.Client;
using V1 = Opc.Ua.ISA95.JobControl.V1;
using V2 = Opc.Ua.ISA95.JobControl.V2;
namespace Opc.Ua.ISA95.Client
{
///
/// Entry point for ISA-95 client discovery and direct Job Control clients.
///
public sealed class Isa95Client
{
///
/// Initializes an ISA-95 client over an existing session.
///
public Isa95Client(ISession session, ITelemetryContext telemetry)
{
Session = session ?? throw new ArgumentNullException(nameof(session));
Telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry));
Isa95EncodeableRegistration.Register(Session);
}
///
/// Gets the session used by this client.
///
public ISession Session { get; }
///
/// Gets the telemetry context used by generated proxies.
///
public ITelemetryContext Telemetry { get; }
///
/// Discovers OPC-10030 common-model objects below a root node.
///
///
///
public async ValueTask> DiscoverCommonObjectsAsync(
NodeId rootNodeId,
bool recursive = true,
CancellationToken ct = default)
{
if (rootNodeId.IsNull)
{
throw new ArgumentException("A root NodeId is required.", nameof(rootNodeId));
}
var entries = new List();
var pending = new Queue();
var visited = new HashSet();
pending.Enqueue(rootNodeId);
visited.Add(rootNodeId);
while (pending.Count > 0)
{
ct.ThrowIfCancellationRequested();
NodeId current = pending.Dequeue();
(ArrayOf> descriptions, ArrayOf errors) =
await Session.ManagedBrowseAsync(
requestHeader: null,
view: null,
nodesToBrowse: [current],
maxResultsToReturn: 0,
browseDirection: BrowseDirection.Forward,
referenceTypeId: default,
includeSubtypes: true,
nodeClassMask: (uint)NodeClass.Object,
ct: ct).ConfigureAwait(false);
if (errors.Count > 0 && StatusCode.IsBad(errors[0].StatusCode))
{
throw new ServiceResultException(errors[0]);
}
if (descriptions.Count == 0)
{
continue;
}
ArrayOf references = descriptions[0];
for (int ii = 0; ii < references.Count; ii++)
{
ReferenceDescription reference = references[ii];
NodeId nodeId = ToNodeId(reference.NodeId);
NodeId typeDefinitionId = ToNodeId(reference.TypeDefinition);
if (nodeId.IsNull)
{
continue;
}
(bool isCommonObject, Isa95CommonObjectKind kind) =
await TryGetCommonKindAsync(
typeDefinitionId,
ct).ConfigureAwait(false);
if (isCommonObject)
{
entries.Add(new Isa95CommonObjectEntry(
nodeId,
typeDefinitionId,
kind,
reference.BrowseName.Name ?? string.Empty));
}
if (recursive && visited.Add(nodeId))
{
pending.Enqueue(nodeId);
}
}
}
return entries.ToArray().ToArrayOf();
}
///
/// Creates a generated PersonType client proxy.
///
public PersonTypeClient CreatePersonClient(NodeId nodeId)
{
return new PersonTypeClient(Session, nodeId, Telemetry);
}
///
/// Creates a generated EquipmentType client proxy.
///
public EquipmentTypeClient CreateEquipmentClient(NodeId nodeId)
{
return new EquipmentTypeClient(Session, nodeId, Telemetry);
}
///
/// Creates a generated PhysicalAssetType client proxy.
///
public PhysicalAssetTypeClient CreatePhysicalAssetClient(NodeId nodeId)
{
return new PhysicalAssetTypeClient(Session, nodeId, Telemetry);
}
///
/// Creates a generated MaterialLotType client proxy.
///
public MaterialLotTypeClient CreateMaterialLotClient(NodeId nodeId)
{
return new MaterialLotTypeClient(Session, nodeId, Telemetry);
}
///
/// Creates a direct V1 Job Control client.
///
public Isa95JobControlV1Client CreateJobControlV1Client(
NodeId jobOrderReceiverId,
NodeId jobResponseProviderId,
NodeId jobResponseReceiverId)
{
return new Isa95JobControlV1Client(
Session,
jobOrderReceiverId,
jobResponseProviderId,
jobResponseReceiverId,
Telemetry);
}
///
/// Creates a direct V2 Job Control client.
///
public Isa95JobControlV2Client CreateJobControlV2Client(
NodeId jobOrderReceiverId,
NodeId jobResponseProviderId,
NodeId jobResponseReceiverId)
{
return new Isa95JobControlV2Client(
Session,
jobOrderReceiverId,
jobResponseProviderId,
jobResponseReceiverId,
Telemetry);
}
///
/// Finds direct children of that implement
/// the V1 or V2 Job Control endpoint object types.
///
///
/// ManagedBrowseAsync follows BrowseNext continuation points. Results
/// retain every endpoint so callers can explicitly handle absent or
/// ambiguous facets instead of silently selecting one.
///
public ValueTask DiscoverJobControlAsync(
NodeId rootNodeId,
CancellationToken ct = default)
{
return DiscoverJobControlCoreAsync(
rootNodeId,
maxFolderDepth: 0,
ct);
}
private async ValueTask DiscoverJobControlCoreAsync(
NodeId rootNodeId,
int maxFolderDepth,
CancellationToken ct)
{
if (rootNodeId.IsNull)
{
throw new ArgumentException("A root NodeId is required.", nameof(rootNodeId));
}
var pending = new Queue<(NodeId NodeId, int Depth)>();
var visited = new HashSet();
pending.Enqueue((rootNodeId, 0));
visited.Add(rootNodeId);
var v1 = new List();
var v2 = new List();
NodeId v1OrderReceiver = ToNodeId(V1.ObjectTypeIds.ISA95JobOrderReceiverObjectType);
NodeId v1ResponseProvider = ToNodeId(V1.ObjectTypeIds.ISA95JobResponseProviderObjectType);
NodeId v1ResponseReceiver = ToNodeId(V1.ObjectTypeIds.ISA95JobResponseReceiverObjectType);
NodeId v2OrderReceiver = ToNodeId(V2.ObjectTypeIds.ISA95JobOrderReceiverObjectType);
NodeId v2OrderReceiverSubStates =
ToNodeId(V2.ObjectTypeIds.ISA95JobOrderReceiverSubStatesType);
NodeId v2ResponseProvider = ToNodeId(V2.ObjectTypeIds.ISA95JobResponseProviderObjectType);
NodeId v2ResponseReceiver = ToNodeId(V2.ObjectTypeIds.ISA95JobResponseReceiverObjectType);
NodeId folderType = Ua.ObjectTypeIds.FolderType;
while (pending.Count > 0)
{
ct.ThrowIfCancellationRequested();
(NodeId current, int depth) = pending.Dequeue();
(ArrayOf> descriptions, ArrayOf errors) =
await Session.ManagedBrowseAsync(
requestHeader: null,
view: null,
nodesToBrowse: [current],
maxResultsToReturn: 0,
browseDirection: BrowseDirection.Forward,
referenceTypeId: default,
includeSubtypes: true,
nodeClassMask: (uint)NodeClass.Object,
ct: ct).ConfigureAwait(false);
if (errors.Count > 0 && StatusCode.IsBad(errors[0].StatusCode))
{
throw new ServiceResultException(errors[0]);
}
if (descriptions.Count == 0)
{
continue;
}
ArrayOf references = descriptions[0];
for (int ii = 0; ii < references.Count; ii++)
{
ReferenceDescription reference = references[ii];
ct.ThrowIfCancellationRequested();
NodeId instanceId = ToNodeId(reference.NodeId);
NodeId typeDefinitionId = ToNodeId(reference.TypeDefinition);
if (instanceId.IsNull || typeDefinitionId.IsNull)
{
continue;
}
string browseName = reference.BrowseName.Name ?? string.Empty;
(bool isV1Facet, Isa95JobControlFacet v1Facet) =
await TryGetFacetAsync(
typeDefinitionId,
v1OrderReceiver,
v1ResponseProvider,
v1ResponseReceiver,
ct).ConfigureAwait(false);
if (isV1Facet)
{
v1.Add(new Isa95JobControlEndpoint(
instanceId,
typeDefinitionId,
v1Facet,
browseName));
}
else
{
(bool isV2Facet, Isa95JobControlFacet v2Facet) =
await TryGetFacetAsync(
typeDefinitionId,
v2OrderReceiver,
v2ResponseProvider,
v2ResponseReceiver,
ct,
v2OrderReceiverSubStates).ConfigureAwait(false);
if (isV2Facet)
{
v2.Add(new Isa95JobControlEndpoint(
instanceId,
typeDefinitionId,
v2Facet,
browseName));
}
else if (depth < maxFolderDepth &&
await Session.NodeCache.IsTypeOfAsync(
typeDefinitionId,
folderType,
ct).ConfigureAwait(false) &&
visited.Add(instanceId))
{
pending.Enqueue((instanceId, depth + 1));
}
}
}
}
return new Isa95JobControlDiscovery(
v1.ToArray().ToArrayOf(),
v2.ToArray().ToArrayOf());
}
///
/// Finds Job Control endpoint objects below the standard Objects folder.
///
public ValueTask DiscoverJobControlAsync(CancellationToken ct = default)
{
return DiscoverJobControlCoreAsync(
Ua.ObjectIds.ObjectsFolder,
maxFolderDepth: 1,
ct);
}
private NodeId ToNodeId(ExpandedNodeId nodeId)
{
return ExpandedNodeId.ToNodeId(nodeId, Session.NamespaceUris);
}
private async ValueTask<(bool Found, Isa95JobControlFacet Facet)> TryGetFacetAsync(
NodeId typeDefinitionId,
NodeId orderReceiverTypeId,
NodeId responseProviderTypeId,
NodeId responseReceiverTypeId,
CancellationToken ct,
NodeId additionalOrderReceiverTypeId = default)
{
if (await Session.NodeCache.IsTypeOfAsync(
typeDefinitionId,
orderReceiverTypeId,
ct).ConfigureAwait(false) ||
(!additionalOrderReceiverTypeId.IsNull &&
await Session.NodeCache.IsTypeOfAsync(
typeDefinitionId,
additionalOrderReceiverTypeId,
ct).ConfigureAwait(false)))
{
return (true, Isa95JobControlFacet.JobOrderReceiver);
}
if (await Session.NodeCache.IsTypeOfAsync(
typeDefinitionId,
responseProviderTypeId,
ct).ConfigureAwait(false))
{
return (true, Isa95JobControlFacet.JobResponseProvider);
}
if (await Session.NodeCache.IsTypeOfAsync(
typeDefinitionId,
responseReceiverTypeId,
ct).ConfigureAwait(false))
{
return (true, Isa95JobControlFacet.JobResponseReceiver);
}
return (false, default);
}
private async ValueTask<(bool Found, Isa95CommonObjectKind Kind)> TryGetCommonKindAsync(
NodeId typeDefinitionId,
CancellationToken ct)
{
(ExpandedNodeId TypeId, Isa95CommonObjectKind Kind)[] mappings =
[
(ObjectTypeIds.PersonnelClassType, Isa95CommonObjectKind.PersonnelClass),
(ObjectTypeIds.PersonType, Isa95CommonObjectKind.Person),
(ObjectTypeIds.EquipmentClassType, Isa95CommonObjectKind.EquipmentClass),
(ObjectTypeIds.EquipmentType, Isa95CommonObjectKind.Equipment),
(ObjectTypeIds.PhysicalAssetClassType, Isa95CommonObjectKind.PhysicalAssetClass),
(ObjectTypeIds.PhysicalAssetType, Isa95CommonObjectKind.PhysicalAsset),
(ObjectTypeIds.MaterialClassType, Isa95CommonObjectKind.MaterialClass),
(ObjectTypeIds.MaterialDefinitionType, Isa95CommonObjectKind.MaterialDefinition),
(ObjectTypeIds.MaterialLotType, Isa95CommonObjectKind.MaterialLot),
(ObjectTypeIds.MaterialSublotType, Isa95CommonObjectKind.MaterialSublot)
];
foreach ((ExpandedNodeId typeId, Isa95CommonObjectKind mappedKind) in mappings)
{
if (await Session.NodeCache.IsTypeOfAsync(
typeDefinitionId,
ToNodeId(typeId),
ct).ConfigureAwait(false))
{
return (true, mappedKind);
}
}
return (false, default);
}
}
///
/// Job Control endpoints found for the two ISA-95 Job Control namespaces.
///
public sealed class Isa95JobControlDiscovery
{
///
/// Initializes a discovery result.
///
public Isa95JobControlDiscovery(
ArrayOf v1Endpoints,
ArrayOf v2Endpoints)
{
V1Endpoints = v1Endpoints;
V2Endpoints = v2Endpoints;
}
///
/// Gets endpoints with V1 type definitions.
///
public ArrayOf V1Endpoints { get; }
///
/// Gets endpoints with V2 type definitions.
///
public ArrayOf V2Endpoints { get; }
}
///
/// Describes a discovered Job Control endpoint object.
///
public sealed record Isa95JobControlEndpoint(
NodeId NodeId,
NodeId TypeDefinitionId,
Isa95JobControlFacet Facet,
string BrowseName);
///
/// ISA-95 Job Control endpoint facets.
///
public enum Isa95JobControlFacet
{
///
/// Job Order Receiver endpoint.
///
JobOrderReceiver,
///
/// Job Response Provider endpoint.
///
JobResponseProvider,
///
/// Job Response Receiver endpoint.
///
JobResponseReceiver
}
///
/// A discovered OPC-10030 common-model object.
///
public sealed record Isa95CommonObjectEntry(
NodeId NodeId,
NodeId TypeDefinitionId,
Isa95CommonObjectKind Kind,
string BrowseName);
///
/// Primary OPC-10030 common-model object categories.
///
public enum Isa95CommonObjectKind
{
PersonnelClass,
Person,
EquipmentClass,
Equipment,
PhysicalAssetClass,
PhysicalAsset,
MaterialClass,
MaterialDefinition,
MaterialLot,
MaterialSublot
}
}