diff --git a/.gitattributes b/.gitattributes index eda18c728a..e56fe8f67b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -28,3 +28,7 @@ # OPC UA *.uanodes binary + +# Preserve official NodeSet artifacts byte-for-byte. +src/Opc.Ua.Positioning/Model/*.csv binary +src/Opc.Ua.Positioning/Model/*.xml binary diff --git a/Directory.Build.targets b/Directory.Build.targets index c0d9129dc0..ff434bfa78 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -4,13 +4,25 @@ sets `true` in its main `PropertyGroup`, this Directory.Build.targets makes the project a no-op build (empty Library, no compile items, no references) for - `dotnet build โ€ฆ /p:CustomTestTarget=` invocations where - `` is one of `net472` / `net48` / `netstandard2.0` / - `netstandard2.1`. Use this for projects that are `.NET 8+` only + `dotnet build โ€ฆ /p:CustomTestTarget=` invocations that the + project cannot take part in. That is the case for + `` in `net472` / `net48` / `netstandard2.0` / `netstandard2.1`, + and for any project that pins a single `TargetFramework` different + from ``. Use this for projects that are `.NET 8+` only (e.g. `PublishAot=true`, `ModelContextProtocol` server, TUnit AOT tests) and therefore can't participate in a `UA.slnx` build pinned to a legacy TFM via `CustomTestTarget`. + The single-`TargetFramework` case matters because `CustomTestTarget` + only redirects the shared TFM variables in `targets.props`; a project + that hardcodes, say, `net10.0` keeps compiling against its own + reference assemblies while the rest of the stack is built for + ``. Mixing the two surfaces as `CS0433` on every BCL type the + stack polyfills for older frameworks (`System.Threading.Lock` is + defined by `Opc.Ua.Types` on `net8.0` and by `System.Runtime` on + `net9.0+`), which is a property of the pinned build rather than of + the project's own sources. + The project still loads in the `.slnx` graph so the matrix prep script discovers it, but `Compile` / `ProjectReference` / `PackageReference` / `AdditionalFiles` / `None` / `Content` are all @@ -32,6 +44,11 @@ <_RestrictedToLegacyTfm Condition="'$(RestrictForLegacyTfm)' == 'true' and ('$(CustomTestTarget)' == 'net48' or '$(CustomTestTarget)' == 'net472' or '$(CustomTestTarget)' == 'netstandard2.0' or '$(CustomTestTarget)' == 'netstandard2.1')">true + + <_RestrictedToLegacyTfm + Condition="'$(RestrictForLegacyTfm)' == 'true' and '$(CustomTestTarget)' != '' and '$(TargetFrameworks)' == '' and '$(TargetFramework)' != '' and '$(CustomTestTarget)' != '$(TargetFramework)'">true Library diff --git a/Directory.Packages.props b/Directory.Packages.props index 254bbc830d..2df0eaa5d3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -128,6 +128,18 @@ + + + + + + + + + + + Cell.usda + robot.usda + tool.usda + + diff --git a/samples/MinimalRobotServer/MobileRobotPositionOptions.cs b/samples/MinimalRobotServer/MobileRobotPositionOptions.cs new file mode 100644 index 0000000000..91e2114e37 --- /dev/null +++ b/samples/MinimalRobotServer/MobileRobotPositionOptions.cs @@ -0,0 +1,162 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Robotics +{ + /// + /// Supported deterministic motion styles for a robot mount. + /// + public enum RobotMotionMode + { + /// + /// The robot stays at its origin and keeps a fixed heading. + /// + Fixed, + + /// + /// The robot traces a lemniscate around its origin. + /// + FigureEight, + + /// + /// The robot traces a circle around its origin. + /// + Circle, + + /// + /// The robot travels back and forth along the X axis. + /// + Shuttle + } + + /// + /// Per-robot motion configuration. + /// + public sealed class RobotMotionOptions + { + /// + /// The motion style the robot follows. + /// + public RobotMotionMode Mode { get; set; } = RobotMotionMode.FigureEight; + + /// + /// The X coordinate the path is centred on, in metres. + /// + public double OriginX { get; set; } + + /// + /// The Y coordinate the path is centred on, in metres. + /// + public double OriginY { get; set; } + + /// + /// The Z coordinate the path is centred on, in metres. + /// + public double OriginZ { get; set; } + + /// + /// Half the width of the + /// path, in metres. + /// + public double AmplitudeX { get; set; } = 0.6; + + /// + /// Half the height of the + /// path, in metres. + /// + public double AmplitudeY { get; set; } = 0.35; + + /// + /// The radius of the path, in + /// metres. + /// + public double Radius { get; set; } = 0.5; + + /// + /// The travel distance of the + /// path, in metres. + /// + public double ShuttleDistance { get; set; } = 0.8; + + /// + /// The time the robot takes to complete one full path, in seconds. + /// + public double PeriodSeconds { get; set; } = 20.0; + + /// + /// The offset into the path the robot starts from, in seconds. Lets + /// two robots sharing a path stay apart. + /// + public double PhaseSeconds { get; set; } + + /// + /// The heading held when is + /// false, in degrees. + /// + public double FixedHeadingDegrees { get; set; } + + /// + /// Whether the heading is derived from the direction of travel rather + /// than from . + /// + public bool HeadingFollowsPath { get; set; } = true; + } + + /// + /// Position simulation configuration for both mobile robots. + /// + public sealed class MobileRobotPositionOptions + { + /// + /// Creates the options with the two robots placed either side of the + /// origin and offset in phase so they do not overlap. + /// + public MobileRobotPositionOptions() + { + R1.OriginX = -1.2; + R2.OriginX = 1.2; + R2.PhaseSeconds = 10.0; + } + + /// + /// Motion configuration for the first robot. + /// + public RobotMotionOptions R1 { get; set; } = new(); + + /// + /// Motion configuration for the second robot. + /// + public RobotMotionOptions R2 { get; set; } = new(); + + /// + /// How often a new position is published, in milliseconds. + /// + public int UpdateIntervalMilliseconds { get; set; } = 200; + } +} diff --git a/samples/MinimalRobotServer/MobileRobotPositionProvider.cs b/samples/MinimalRobotServer/MobileRobotPositionProvider.cs new file mode 100644 index 0000000000..454f0ef6d3 --- /dev/null +++ b/samples/MinimalRobotServer/MobileRobotPositionProvider.cs @@ -0,0 +1,225 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Microsoft.Extensions.Options; +using Opc.Ua; +using Opc.Ua.Gpos; +using Opc.Ua.Positioning; +using Opc.Ua.Positioning.Server; + +namespace Robotics +{ + /// + /// Deterministic sample provider that publishes a global location per robot. + /// + public sealed class MobileRobotPositionProvider : IGeoLocationProvider + { + /// + /// The EPSG code of WGS84, which the cell's GlobalLocation Variables + /// are configured for. + /// + private const uint WGS84EpsgCode = 4326; + + private readonly MobileRobotPositionOptions m_options; + private readonly TimeProvider m_timeProvider; + private readonly DateTimeOffset m_startedAt; + + public MobileRobotPositionProvider( + IOptions options, + RobotPositioningScenario scenario) + : this(options.Value, scenario, TimeProvider.System) + { + } + + public MobileRobotPositionProvider( + MobileRobotPositionOptions options, + RobotPositioningScenario scenario, + TimeProvider timeProvider) + { + m_options = options ?? throw new ArgumentNullException(nameof(options)); + Scenario = scenario ?? throw new ArgumentNullException(nameof(scenario)); + m_timeProvider = timeProvider ?? + throw new ArgumentNullException(nameof(timeProvider)); + m_startedAt = m_timeProvider.GetUtcNow(); + } + + public RobotPositioningScenario Scenario { get; } + + /// + public bool SupportsPush => true; + + /// + public ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + TimeSpan elapsed = m_timeProvider.GetUtcNow() - m_startedAt; + return new ValueTask( + CreateSample(sourceId, elapsed)); + } + + /// + public async IAsyncEnumerable WatchAsync( + string sourceId, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + RobotMotionOptions options = GetOptions(sourceId); + if (options.Mode == RobotMotionMode.Fixed) + { + yield break; + } + + var interval = TimeSpan.FromMilliseconds( + Math.Max(20, m_options.UpdateIntervalMilliseconds)); + while (!cancellationToken.IsCancellationRequested) + { +#if NET8_0_OR_GREATER + await Task.Delay( + interval, + m_timeProvider, + cancellationToken).ConfigureAwait(false); +#else + await Task.Delay( + interval, + cancellationToken).ConfigureAwait(false); +#endif + TimeSpan elapsed = m_timeProvider.GetUtcNow() - m_startedAt; + yield return CreateSample(sourceId, elapsed); + } + } + + internal ThreeDFrame EvaluateLocalFrame( + string sourceId, + TimeSpan elapsed) + { + RobotMotionOptions options = GetOptions(sourceId); + double period = Math.Max(0.1, options.PeriodSeconds); + double theta = 2.0 * + Math.PI * + (elapsed.TotalSeconds + options.PhaseSeconds) / + period; + double x = options.OriginX; + double y = options.OriginY; + double dx = 1.0; + double dy = 0.0; + + switch (options.Mode) + { + case RobotMotionMode.FigureEight: + x += options.AmplitudeX * Math.Sin(theta); + y += options.AmplitudeY * Math.Sin(2.0 * theta); + dx = options.AmplitudeX * Math.Cos(theta); + dy = 2.0 * options.AmplitudeY * Math.Cos(2.0 * theta); + break; + case RobotMotionMode.Circle: + x += options.Radius * Math.Cos(theta); + y += options.Radius * Math.Sin(theta); + dx = -options.Radius * Math.Sin(theta); + dy = options.Radius * Math.Cos(theta); + break; + case RobotMotionMode.Shuttle: + x += options.ShuttleDistance * Math.Sin(theta); + dx = options.ShuttleDistance * Math.Cos(theta); + break; + case RobotMotionMode.Fixed: + break; + default: + throw new InvalidOperationException( + $"Unsupported robot motion mode '{options.Mode}'."); + } + + double heading = options.HeadingFollowsPath && + options.Mode != RobotMotionMode.Fixed + ? Math.Atan2(dy, dx) * (180.0 / Math.PI) + : options.FixedHeadingDegrees; + return new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates + { + X = x, + Y = y, + Z = options.OriginZ + }, + Orientation = new ThreeDOrientation + { + A = 0.0, + B = 0.0, + C = heading + } + }; + } + + private GeoLocationSample CreateSample( + string sourceId, + TimeSpan elapsed) + { + ThreeDFrame local = EvaluateLocalFrame(sourceId, elapsed); + S3DGeographicCoordinateDataType geographic = Scenario.Fit.LocalToGlobal( + local.CartesianCoordinates, + AngleUnit.Degrees); + bool hasElevation = (geographic.EncodingMask & + (uint)S3DGeographicCoordinateDataTypeFields.Elevation) != 0; + var position = new GeoPosition( + geographic.Latitude, + geographic.Longitude, + hasElevation ? geographic.Elevation : null, + Accuracy: 0.05, + EpsgCode: WGS84EpsgCode); + var orientation = new GeoOrientation( + local.Orientation.A, + local.Orientation.B, + local.Orientation.C); + return new GeoLocationSample( + position, + orientation, + default, + StatusCodes.Good, + new DateTimeUtc(m_timeProvider.GetUtcNow()), + sourceId); + } + + private RobotMotionOptions GetOptions(string sourceId) + { + return sourceId switch + { + "R1" => m_options.R1, + "R2" => m_options.R2, + _ => throw new ArgumentException( + $"Unknown robot positioning source '{sourceId}'.", + nameof(sourceId)) + }; + } + } +} diff --git a/samples/MinimalRobotServer/OpenUsdRepresentation.cs b/samples/MinimalRobotServer/OpenUsdRepresentation.cs new file mode 100644 index 0000000000..800add6ee5 --- /dev/null +++ b/samples/MinimalRobotServer/OpenUsdRepresentation.cs @@ -0,0 +1,317 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.OpenUsd; +using Opc.Ua.OpenUsd.Server; +using Opc.Ua.Server; + +namespace Robotics +{ + /// + /// Wires the draft OPC UA โ€” OpenUSD Bindings companion model onto the Robotics + /// server: the well-known OpenUSD facility (stage + representation registries), + /// a RobotCellStage descriptor, and the generic representation/binding helpers + /// used by RobotCell.cs. + /// + public sealed partial class RobotCell + { + private OpenUsdRootState? m_openUsdRoot; + private OpenUsdStageState? m_cellStage; + + private const string CellRootLayerIdentifier = "asset-repo/Cell.usd"; + + private async ValueTask MaterialiseOpenUsdFacilityAsync( + CancellationToken cancellationToken) + { + try + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); + + OpenUsdRootState root = SystemContext.CreateInstanceOfOpenUsdRootType( + null!, new QualifiedName("OpenUSD", ns)); + root.NodeId = new NodeId("OpenUSD", ns); + + FolderState stages = root.Stages + ?? root.CreateOrReplaceStages(SystemContext, null!); + _ = root.Representations + ?? root.CreateOrReplaceRepresentations(SystemContext, null!); + + m_cellStage = SystemContext.CreateInstanceOfOpenUsdStageType( + stages, new QualifiedName("RobotCellStage", ns)); + stages.AddChild(m_cellStage); + m_cellStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!) + .Value = CellRootLayerIdentifier; + + // ยง5.2 Twin-BOM content integrity: the digest is computed over the + // *resolved root-layer content*, never over the identifier string, so a + // connector can detect tampering of the bytes it actually composes. + List servedAssets = LoadServedAssets(); + byte[] rootLayerBytes = servedAssets + .Find(a => a.Kind == OpenUsdAssetKindEnum.RootLayer)!.Bytes; + byte[] digest; +#pragma warning disable CA1850 // Prefer static HashData (net48/netstandard2.0 compatibility) + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + digest = sha.ComputeHash(rootLayerBytes); + } +#pragma warning restore CA1850 + m_cellStage.CreateOrReplaceRootLayerDigest( + SystemContext, + SystemContext.CreateOpenUsdStageType_RootLayerDigest(m_cellStage, forInstance: true)) + .Value = (ByteString)digest; + m_cellStage.CreateOrReplaceRootLayerDigestAlgorithm( + SystemContext, + SystemContext.CreateOpenUsdStageType_RootLayerDigestAlgorithm(m_cellStage, forInstance: true)) + .Value = OpenUsdDigestAlgorithmEnum.Sha256; + + // Per the companion spec ยง4.2 the OpenUSD facility SHALL be a component + // of the Server Object (i=2253) so any conformant connector can Browse + // Server -> OpenUSD -> Representations. Record the inverse reference + // here; the matching forward reference from the Server Object is added + // via the externalReferences dictionary in LinkOpenUsdRootToServer. + root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server); + + // ยง5.15 asset content delivery (OU-AssetDelivery): serve this stage's + // artist-authored USD layer closure so a connector can render the twin + // with no external asset resolver. + UsdAssetDelivery.AttachStageAssets(SystemContext, m_cellStage, ns, servedAssets); + + SystemContext.AssignInstanceChildNodeIds(root); + await Manager.AddPredefinedNodeAsync(root, cancellationToken) + .ConfigureAwait(false); + + m_openUsdRoot = root; + await LinkOpenUsdRootToServerAsync(cancellationToken).ConfigureAwait(false); + m_logger.MaterialisedOpenUsdFacility(root.NodeId, m_cellStage.NodeId); + } + catch (Exception ex) + { + m_cellStage = null; + m_openUsdRoot = null; + m_logger.OpenUsdFacilityFailed(ex); + } + } + + /// + /// Loads the embedded artist-authored USD layers this server serves (spec ยง5.15). + /// + /// + private static List LoadServedAssets() + { + return + [ + new("Cell.usda", OpenUsdAssetKindEnum.RootLayer, ReadEmbeddedAsset("Cell.usda")), + new("robot.usda", OpenUsdAssetKindEnum.Reference, ReadEmbeddedAsset("robot.usda")), + new("tool.usda", OpenUsdAssetKindEnum.Reference, ReadEmbeddedAsset("tool.usda")) + ]; + } + + private static byte[] ReadEmbeddedAsset(string resourceName) + { + using Stream? s = typeof(RobotCell).Assembly.GetManifestResourceStream(resourceName); + if (s == null) + { + return []; + } + using var ms = new MemoryStream(); + s.CopyTo(ms); + return ms.ToArray(); + } + + private async ValueTask LinkOpenUsdRootToServerAsync( + CancellationToken cancellationToken) + { + if (m_openUsdRoot == null) + { + return; + } + + var references = new IReference[] + { + new NodeStateReference( + ReferenceTypeIds.HasComponent, + false, + m_openUsdRoot.NodeId) + }; + await Server.NodeManager.AddReferencesAsync( + Opc.Ua.ObjectIds.Server, + references, + cancellationToken).ConfigureAwait(false); + m_logger.LinkedOpenUsdFacility(); + } + + /// + /// Attaches an OpenUsdRepresentation AddIn to an Object and registers it in the + /// discovery registry (Server/OpenUSD/Representations, Organizes). Returns the + /// representation so the caller can add live/component bindings to it. + /// + /// + /// + /// + /// + private OpenUsdRepresentationState AttachRepresentation( + NodeState owner, string primPath, ushort ns) + { + OpenUsdRepresentationState rep = SystemContext + .CreateInstanceOfOpenUsdRepresentationType( + owner, new QualifiedName("OpenUsdRepresentation", ns)); + // The instance factory leaves ReferenceTypeId = Null; set HasComponent so + // the AddIn is browsable from the represented object. + rep.ReferenceTypeId = ReferenceTypeIds.HasComponent; + owner.AddChild(rep); + rep.NodeId = SystemContext.NodeIdFactory.New(SystemContext, rep); + rep.CreateOrReplaceStage(SystemContext, null!).Value = m_cellStage!.NodeId; + rep.CreateOrReplacePrimPath(SystemContext, null!).Value = primPath; + AssignInstanceSubtree(rep, owner); + return rep; + } + + /// + /// Adds the Organizes reference pair between the discovery registry and a + /// representation, so a generic connector discovers it without domain knowledge. + /// + /// + private void OrganiseRepresentation(OpenUsdRepresentationState rep) + { + FolderState? registry = m_openUsdRoot?.Representations; + if (registry == null) + { + return; + } + registry.AddReference(ReferenceTypeIds.Organizes, false, rep.NodeId); + rep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId); + } + + /// + /// Instantiates the OpenUsdLiveBinding placeholder as a concrete + /// browsable child of a representation and sets its members (spec ยง5.4/ยง5.10/ยง5.11). + /// Thin adapter over the reusable Opc.Ua.OpenUsd.Server authoring API: binds the + /// cell stage and forwards. The binding-authoring logic lives in the SDK + /// (OpenUsdRepresentationAuthoring.AddLiveBinding), not in this sample. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + private OpenUsdLiveBindingState CreateBinding( + OpenUsdRepresentationState rep, ushort ns, string name, + Guid bindingDefinitionId, NodeId sourceNodeId, string targetPrimPath, + string targetPropertyName, string targetUsdTypeName, + OpenUsdRenderTargetKindEnum? kind, double scale, + uint bindingTypeId = Opc.Ua.OpenUsd.ObjectTypes.OpenUsdValueChangeBindingType, + OpenUsdSignalRoleEnum signalRole = OpenUsdSignalRoleEnum.Observable, + string? sourceSemanticId = null, + OpenUsdAlarmAspectEnum? alarmAspect = null, + NodeId commandTargetNodeId = default, + string? commandTriggerPropertyName = null) + { + OpenUsdLiveBindingState binding = rep.AddLiveBinding( + SystemContext, ns, m_cellStage!.NodeId, name, bindingDefinitionId, sourceNodeId, + targetPrimPath, targetPropertyName, targetUsdTypeName, kind, scale, + bindingTypeId, signalRole, sourceSemanticId, alarmAspect, + commandTargetNodeId, commandTriggerPropertyName); + AssignInstanceSubtree(binding, rep); + return binding; + } + + /// + /// Instantiates the OpenUsdComponentBinding placeholder on a + /// representation and sets its members (spec ยง5.12โ€“5.14). + /// Thin adapter over the reusable Opc.Ua.OpenUsd.Server authoring API: the + /// component/composition-binding logic lives in the SDK + /// (OpenUsdRepresentationAuthoring.AddComponentBinding), not in this sample. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + private void CreateComponentBinding( + OpenUsdRepresentationState rep, ushort ns, string name, Guid bindingDefinitionId, + OpenUsdCardinalityEnum cardinality, OpenUsdCompositionArcEnum arc, string targetPrimPath, + NodeId componentRepresentation = default, string? assetReference = null, + bool dynamic = false, NodeId changeEventSource = default, + NodeId componentTypeDefinition = default) + { + OpenUsdComponentBindingState binding = rep.AddComponentBinding( + SystemContext, ns, name, bindingDefinitionId, cardinality, arc, targetPrimPath, + componentRepresentation, assetReference, dynamic, changeEventSource, + componentTypeDefinition: componentTypeDefinition); + AssignInstanceSubtree(binding, rep); + } + } + + internal static partial class OpenUsdRepresentationLog + { + [LoggerMessage(EventId = MinimalRobotServerEventIds.OpenUsdRepresentation + 1, + Level = LogLevel.Information, + Message = "Materialised OpenUSD facility (root {RootId}, RobotCellStage {StageId}).")] + public static partial void MaterialisedOpenUsdFacility( + this ILogger logger, NodeId rootId, NodeId stageId); + + [LoggerMessage(EventId = MinimalRobotServerEventIds.OpenUsdRepresentation + 2, + Level = LogLevel.Error, + Message = "Failed to materialise the OpenUSD facility.")] + public static partial void OpenUsdFacilityFailed( + this ILogger logger, + Exception exception); + + [LoggerMessage(EventId = MinimalRobotServerEventIds.OpenUsdRepresentation + 3, + Level = LogLevel.Information, + Message = "Linked OpenUSD facility under the Server Object (i=2253).")] + public static partial void LinkedOpenUsdFacility(this ILogger logger); + } +} diff --git a/samples/MinimalRobotServer/Program.cs b/samples/MinimalRobotServer/Program.cs new file mode 100644 index 0000000000..bdb2183afb --- /dev/null +++ b/samples/MinimalRobotServer/Program.cs @@ -0,0 +1,85 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Opc.Ua.Positioning.Server.Hosting; +using Opc.Ua.Robotics.Server; +using Robotics; + +// Self-contained OPC UA server exposing an OPC 40010 Robotics MotionDeviceSystem +// (a robot cell of two 6-axis articulated robots) bound to OpenUSD via the draft +// OPC UA - OpenUSD Bindings companion model. A generic connector renders the cell +// live: each Axis' ActualPosition articulates one joint, the cell emergency-stop +// drives a safety visual, a gripper tool is composed dynamically, and the robots +// compose recursively (system -> devices -> axes). +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); + +int port = int.TryParse(builder.Configuration["port"], out int p) ? p : 62830; + +// Bind host for the OPC UA endpoint. Defaults to 0.0.0.0 so the server is reachable +// from outside a container; override with --host / host env var (e.g. "localhost"). +string host = builder.Configuration["host"] is { Length: > 0 } h ? h : "0.0.0.0"; + +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Robots")); +builder.Services.AddSingleton(); + +IPositioningServerBuilder positioning = builder.Services + .AddOpcUa() + .AddServer(o => + { + o.ApplicationName = "MinimalRobotServer"; + o.ApplicationUri = "urn:localhost:OPCFoundation:MinimalRobotServer"; + o.ProductUri = "uri:opcfoundation.org:MinimalRobotServer"; + o.AutoAcceptUntrustedCertificates = true; + o.EndpointUrls.Add($"opc.tcp://{host}:{port}/MinimalRobotServer"); + }) + .AddRobotics() + .AddRoboticsModel() + .AddRoboticsModel() + .AddRoboticsModel() + .ConfigureRobotics() + .ConfigureRobotics(async context => + await context.GetRequiredService() + .RunAsync(context.Manager, context.CancellationToken).ConfigureAwait(false)) + .AddPositioningFor(); + +positioning + .AddGeoLocationProvider() + .ConfigurePositioningFor( + context => RobotCell.GetForManager(context.Manager) + .ConfigurePositioningAsync(context)); + +using IHost app = builder.Build(); +await app.RunAsync().ConfigureAwait(false); diff --git a/samples/MinimalRobotServer/Properties/AssemblyInfo.cs b/samples/MinimalRobotServer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..2b9848014c --- /dev/null +++ b/samples/MinimalRobotServer/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * 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; + +[assembly: CLSCompliant(false)] diff --git a/samples/MinimalRobotServer/README.md b/samples/MinimalRobotServer/README.md new file mode 100644 index 0000000000..7eb27de1d2 --- /dev/null +++ b/samples/MinimalRobotServer/README.md @@ -0,0 +1,157 @@ +# MinimalRobotServer + +A minimal, self-contained .NET console OPC UA server that demonstrates the **OPC 40010 +Robotics**, **OPC 10000-210 Relative Spatial Location**, and **OPC 10000-211 +Global Positioning** companion specifications bound to **OpenUSD** through the draft +[*OPC UA โ€” OpenUSD Bindings*](../../../opcua-drafts/core-specs/openusd-binding/OPC-UA-OpenUSD-Bindings.md) +companion model, so a **generic** connector renders a robot cell live with **no +robot-specific code**. It is built on the `Opc.Ua.Robotics` and `Opc.Ua.OpenUsd` +SDK libraries. See [`docs/Robotics.md`](../../docs/Robotics.md) for the Robotics +developer guide and [`docs/OpenUsd.md`](../../docs/OpenUsd.md) for the OpenUSD +binding. + +It is the Robotics counterpart of `PumpDeviceIntegrationServer` and is validated +end-to-end by `RobotOpenUsdE2eTests` (in `tests/Opc.Ua.Di.Tests`). + +## What it exposes + +A `MotionDeviceSystem` **"RobotCell"** (prim `/Cell`) composed recursively of: + +- **Two 6-axis articulated robots** (`MotionDeviceType` `R1`, `R2` โ†’ `/Cell/Robots/R1`, + `/Cell/Robots/R2`), aggregated 1..n with a `Many` / `Reference` `` + binding (Reference โ€” not Instance โ€” so each robot articulates independently). +- **Six axes per robot** (`AxisType` `A1..A6`), aggregated with a nested `Many` / + `Child` `` binding. Each Axis' `ParameterSet/ActualPosition` (degrees) + drives one joint `xformOp:rotate{Z|Y|X}` on the (pre-authored) `robot.usda` + kinematic chain โ€” the live articulation (`RenderTargetKind = Rotation`). The axis + limits are those of the reference robot (see below), and the simulation runs an eased + pick-and-place cycle rather than a free sweep. +- A cell **EmergencyStop** safety state driving a beacon and per-robot warning halo + visibility (`UaAlarmToUsd`, `Visibility`). +- An opt-in **SpeedOverride** command (`UsdToUaCommand`, fail-closed). +- A **gripper tool** mounted on R1's flange at runtime (`One` / `Reference`, + `Dynamic = true`) via a model-change event. +- One RSL spatial-object list with a world frame, R1/R2 SpatialObject AddIns, + each robot's PositionFrame, and R1's ToolFlange AttachPoint. +- One GPOS Zone with ground-control points and one live GlobalLocation per + robot. Each robot independently selects `Fixed`, `FigureEight`, `Circle`, or + `Shuttle` motion; the default uses phase-shifted figure-eight paths. + +All 15 representations (1 system + 2 robots + 12 axes) are discoverable through the +well-known `Server/OpenUSD/Representations` registry. + +## The USD assets + +`Assets/robot.usda`, `Assets/tool.usda` and `Assets/Cell.usda` are embedded in the server +and served over Part 5 `FileType` with SHA-256 digests, so a connector can fetch the whole +closure and render the twin without any external asset resolver. + +### Reference robot โ€” KUKA KR 16-2 + +`robot.usda` reproduces the published kinematics of a **KUKA KR 16-2**, taken from the +ROS-Industrial URDF (`ros-industrial/kuka_experimental`, `kuka_kr16_support`): + +| Joint | Offset from the previous axis | Rotate op | Limit | +| --- | --- | --- | --- | +| A1 | 675 mm up from the mounting face | `xformOp:rotateZ` | ยฑ185ยฐ | +| A2 | 260 mm forward | `xformOp:rotateY` | โˆ’155ยฐโ€ฆ+35ยฐ | +| A3 | 680 mm along the upper arm | `xformOp:rotateY` | โˆ’130ยฐโ€ฆ+154ยฐ | +| A4 | 670 mm along the forearm, โˆ’35 mm lateral | `xformOp:rotateX` | ยฑ350ยฐ | +| A5 | coincident with A4 | `xformOp:rotateY` | ยฑ130ยฐ | +| A6 | coincident with A4 | `xformOp:rotateX` | ยฑ350ยฐ | +| Flange | 158 mm past the wrist centre | โ€” | โ€” | + +`260 + 680 + 670 = 1611 mm`, the robot's published reach. A4, A5 and A6 are coincident, +which is the real spherical wrist. The livery is KUKA orange over dark cast housings, and +the cell is scaled around that reach: a 7.2 ร— 4.8 m floor with the guarding at +x = ยฑ3.4 m, y = ยฑ2.2 m, plus a controller cabinet, a shared work table and a stack light. + +### Mobile platform + +Each arm rides on an omnidirectional AGV (`/Robot/MobileBase`, 1.10 ร— 0.72 m, 365 mm deck) +in the style of a KUKA KMP: orange chassis, dark skirt, four wheels, diagonally opposed +safety scanners, bumpers and status strips. The RSL scenario drives each robot's spatial +location across the cell, so the arm has to be carried rather than bolted to the floor. +The platform footprint and the work-table position are chosen so the two AGVs clear each +other at their closest approach and never drive through the table. + +To floor-mount the arm instead, delete `/Robot/MobileBase` and clear the `xformOp:translate` +on `/Robot/Base`; nothing else depends on it. + +### Editing the assets + +The prim paths and rotate ops above are the **binding contract**: `RobotCell.cs` addresses +them by name, and the connector writes each rotate op as a scalar `double`. Link offsets, +geometry, materials and lighting are all free to change; the paths, op names, +`xformOpOrder` entries, `/Robot/Base/.../Flange`, `/Robot/Warning`, `/Cell/SafetyBeacon`, +`/Cell.inputs:speedOverride` and the `R1`/`R2` mount attributes are not. + +`RobotAssetContractTests` (in `tests/Opc.Ua.OpenUsd.Tests`) parses the shipped assets +and asserts that contract, so an edit that breaks it fails the build instead of silently +freezing the twin. Note that the `.usda` files under +`tests/Opc.Ua.OpenUsd.Tests/Assets` are a **frozen** reader/materializer fixture and +are deliberately *not* kept in sync with these assets. + +Author with real USDA syntax: the managed `UsdaReader` is more forgiving than OpenUSD +itself, so a layer that parses in the tests can still fail to load in a viewer. The +end-to-end run below is the authoritative check. + +## Design note โ€” stock Robotics hosting + +The server uses the stock `Opc.Ua.Robotics.Server` hosting pipeline: + +- `AddRobotics()` registers the sealed stock `RoboticsNodeManager`, DI/IA/Robotics + model provider, and the shared Robotics configurator pipeline. +- `AddRoboticsModel()`, + `AddRoboticsModel()`, and + `AddRoboticsModel()` contribute the additional compiled + OpenUSD, RSL, and GPOS companion models to the same manager. +- `ConfigureRobotics()` builds the cell with the validated fluent + topology builders (`AddMotionDeviceSystemAsync`, `AddMotionDevice`, + `AddAxis`, safety, controller, task-control, and operation builders). +- `AddPositioningFor()` and + `ConfigurePositioningFor(...)` add the RSL/GPOS spatial + objects, frames, attach point, zone, and live global-location bindings after + the Robotics topology exists. + +The OpenUSD facility still lives under the standard `Server/OpenUSD` entry point, +but it is now authored by the `RobotCell` configurator against the stock manager +instead of by subclassing a custom node manager. + +## Run it + +``` +dotnet run --project samples/MinimalRobotServer -- --host localhost --port 62830 +``` + +Then drive it with the **same** generic connector used for pumps +(`Opc.Ua.OpenUsd.Connector`) to author a live USD override layer: + +``` +dotnet run --project tools/Opc.Ua.OpenUsd.Connector -- \ + --server opc.tcp://localhost:62830/MinimalRobotServer \ + --out /live.usda --insecure --seconds 10 +``` + +Compose `live.usda` over the base `Cell.usda` (see the example `stage.usda`) and open +it in `usdview` / NVIDIA Omniverse to see the two arms articulate live. The example +USD assets, descriptor, writer, and a step-by-step guide live in the `opcua-drafts` +repo under `core-specs/extras/openusd-binding/examples/robotics/`. + +To watch the cell animate without leaving the connector, install the optional +`Opc.Ua.OpenUsd.Connector.Viewer` assembly beside it and add `--view`: + +``` +dotnet run --project tools/Opc.Ua.OpenUsd.Connector -- \ + --server opc.tcp://localhost:62830/MinimalRobotServer --insecure --view +``` + +That fetches this server's served asset closure, composes `stage.usda`, opens a viewport, and streams +the same subscribed values into both the override layer and the rendered stage, so the joints move on +screen as the simulation runs. See [`docs/OpenUsd.md`](../../docs/OpenUsd.md#rendering-the-twin-live). + +The robot motion is configured through `RobotMobilityOptions`. RSL +Position/Orientation values author `xformOp:translate` and +`xformOp:rotateXYZ`; GPOS longitude, latitude, and elevation author the +`inputs:longitude`, `inputs:latitude`, and `inputs:elevation` attributes. See +[`docs/Positioning.md`](../../docs/Positioning.md). diff --git a/samples/MinimalRobotServer/RobotCell.Configure.cs b/samples/MinimalRobotServer/RobotCell.Configure.cs new file mode 100644 index 0000000000..7212157828 --- /dev/null +++ b/samples/MinimalRobotServer/RobotCell.Configure.cs @@ -0,0 +1,178 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using Opc.Ua; +using Opc.Ua.Server.Fluent; + +namespace Robotics +{ + /// + /// Sibling partial that animates the robot cell: a single manager-owned + /// simulation tick sweeps every axis about its home pose (so the two arms move + /// live and independently) and briefly asserts the cell emergency-stop so the + /// safety beacon and per-robot warnings blink. + /// + public sealed partial class RobotCell + { + private long m_simulationTicks; + + // 20 Hz. The twin can never move more smoothly than the source publishes, so the + // tick is chosen for animation rather than for a typical telemetry cadence. + private const double TickSeconds = 0.05; + private const int TickMilliseconds = 50; + private const int EstopPeriodTicks = 600; + private const int EstopActiveFromTick = 560; + + /// + /// Key poses of one transfer cycle, in the axis order A1..A6 (degrees). The arm + /// swings to the pick station, descends, closes on the part, lifts clear, traverses + /// while rotating the part upright, places it, and retracts. Each entry moves from + /// the previous pose over MoveSeconds and then holds for DwellSeconds, + /// which is what makes the motion read as a real duty cycle instead of a sweep. + /// + private static readonly (double[] Pose, double MoveSeconds, double DwellSeconds)[] s_cycle = + [ + ([0.0, -60.0, 75.0, 0.0, 45.0, 0.0], 1.6, 0.4), + ([55.0, -45.0, 70.0, 0.0, 45.0, 0.0], 1.8, 0.2), + ([55.0, -30.0, 62.0, 0.0, 58.0, 0.0], 1.0, 0.8), + ([55.0, -55.0, 78.0, 0.0, 47.0, 0.0], 1.0, 0.2), + ([-15.0, -55.0, 78.0, 0.0, 47.0, 90.0], 2.0, 0.2), + ([-45.0, -45.0, 70.0, 0.0, 50.0, 90.0], 1.4, 0.2), + ([-45.0, -28.0, 60.0, 0.0, 58.0, 90.0], 1.0, 0.8), + ([-45.0, -60.0, 80.0, 0.0, 45.0, 0.0], 1.4, 0.3) + ]; + + private static readonly double s_cycleSeconds = ComputeCycleSeconds(); + + partial void Configure(INodeManagerBuilder builder) + { + // Single manager-owned simulation tick advances every axis position and the + // emergency-stop flag. + builder.Simulation(TimeSpan.FromMilliseconds(TickMilliseconds)) + .OnTick((ctx, elapsed) => AdvanceSimulation()); + } + + private void AdvanceSimulation() + { + long t = Interlocked.Increment(ref m_simulationTicks); + double time = t * TickSeconds; + + // Drive every axis from the shared pick-and-place cycle, offset per robot so + // the two arms are not synchronised, and clamp to the axis' own limits. + foreach (AxisRuntime ax in m_axes) + { + double target = EvaluateCycle(time + ax.PhaseSeconds, ax.Index); + if (target < ax.Min) + { + target = ax.Min; + } + if (target > ax.Max) + { + target = ax.Max; + } + UpdateDouble(ax.Position, target); + } + + // Emergency-stop pulses active (~2 s) roughly every 30 s so the safety + // beacon (cell) and warning zones (robots) toggle live. + bool estop = (t % EstopPeriodTicks) >= EstopActiveFromTick; + UpdateBool(m_estopVar, estop); + } + + /// + /// Samples the cycle for one axis at a point in time, easing each move so the arm + /// accelerates and settles rather than stepping between poses. + /// + private static double EvaluateCycle(double time, int axisIndex) + { + double position = time % s_cycleSeconds; + if (position < 0.0) + { + position += s_cycleSeconds; + } + + for (int i = 0; i < s_cycle.Length; i++) + { + (double[] pose, double move, double dwell) = s_cycle[i]; + if (position < move) + { + double[] previous = s_cycle[(i + s_cycle.Length - 1) % s_cycle.Length].Pose; + double u = position / move; + double eased = u * u * (3.0 - (2.0 * u)); + return previous[axisIndex] + ((pose[axisIndex] - previous[axisIndex]) * eased); + } + position -= move; + if (position < dwell) + { + return pose[axisIndex]; + } + position -= dwell; + } + return s_cycle[^1].Pose[axisIndex]; + } + + private static double ComputeCycleSeconds() + { + double total = 0.0; + foreach ((double[] _, double move, double dwell) in s_cycle) + { + total += move + dwell; + } + return total; + } + + private void UpdateDouble(BaseDataVariableState? v, double value) + { + if (v == null) + { + return; + } + v.Value = value; + v.Timestamp = DateTime.UtcNow; + v.ClearChangeMasks(SystemContext, includeChildren: false); + } + + private void UpdateBool(BaseDataVariableState? v, bool value) + { + if (v == null) + { + return; + } + bool current = v.Value.TryGetValue(out bool b) && b; + if (current != value) + { + v.Value = value; + v.Timestamp = DateTime.UtcNow; + v.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + } +} diff --git a/samples/MinimalRobotServer/RobotCell.cs b/samples/MinimalRobotServer/RobotCell.cs new file mode 100644 index 0000000000..f9ede9e0d4 --- /dev/null +++ b/samples/MinimalRobotServer/RobotCell.cs @@ -0,0 +1,839 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Di.Server; +using Opc.Ua.Gpos; +using Opc.Ua.OpenUsd; +using Opc.Ua.Positioning; +using Opc.Ua.Positioning.Server; +using Opc.Ua.Positioning.Server.Hosting; +using Opc.Ua.Robotics; +using Opc.Ua.Robotics.Server; +using Opc.Ua.Robotics.Server.Builders; +using Opc.Ua.Rsl; +using Opc.Ua.Server; +using Opc.Ua.Server.Fluent; +using ReferenceTypeIds = Opc.Ua.ReferenceTypeIds; + +namespace Robotics +{ + /// + /// Builds the sample robot cell using the stock Robotics hosting configurator. + /// + public sealed partial class RobotCell : IRoboticsConfigurator, IDisposable + { + private const string CellPrimPath = "/Cell"; + private const string RobotsScopePrimPath = "/Cell/Robots"; + private const string ToolSuffix = "/Base/J1/J2/J3/J4/J5/J6/Flange/Tool"; + private const uint MotionDeviceTypeId = 1004; + private const uint AxisTypeId = 16601; + + // Axis table of the reference robot, a KUKA KR 16-2. The limits are the published + // software limits; the home values are a plausible ready pose and match the + // defaults authored into robot.usda so the asset looks right when opened on its + // own. LinkPrimPath and RotateOp are the binding contract with the USD asset. + private static readonly ( + string Name, + string LinkPrimPath, + string RotateOp, + double Home, + double Min, + double Max)[] s_axisTemplate = + [ + ("A1", "Base/J1", "xformOp:rotateZ", 0.0, -185.0, 185.0), + ("A2", "Base/J1/J2", "xformOp:rotateY", -60.0, -155.0, 35.0), + ("A3", "Base/J1/J2/J3", "xformOp:rotateY", 75.0, -130.0, 154.0), + ("A4", "Base/J1/J2/J3/J4", "xformOp:rotateX", 0.0, -350.0, 350.0), + ("A5", "Base/J1/J2/J3/J4/J5", "xformOp:rotateY", 45.0, -130.0, 130.0), + ("A6", "Base/J1/J2/J3/J4/J5/J6", "xformOp:rotateX", 0.0, -350.0, 350.0) + ]; + + private static readonly (string BrowseName, string PrimPath, bool HasTool, double PhaseSeconds)[] s_robots = + [ + ("R1", "/Cell/Robots/R1", true, 0.0), + ("R2", "/Cell/Robots/R2", false, 3.0) + ]; + + private static readonly ConditionalWeakTable s_cells = new(); + + private readonly ILogger m_logger; + private readonly List m_axes = []; + private readonly List m_axisReps = []; + private readonly List m_robots = []; + private readonly List m_positioningSubscriptions = []; + private BaseDataVariableState? m_estopVar; + private BaseDataVariableState? m_speedOverrideVar; + private DiNodeManager? m_manager; + private NodeId m_r1NodeId; + private MotionDeviceSystemState? m_robotCell; + + /// + /// Creates the sample Robotics configurator. + /// + /// + /// The configurator logger. + /// + public RobotCell(ILogger logger) + { + m_logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + private DiNodeManager Manager => m_manager ?? throw new InvalidOperationException( + "RobotCell has not been attached to a Robotics build context."); + + private IServerInternal Server => Manager.Server; + + private ServerSystemContext SystemContext => Manager.SystemContext; + + /// + public async ValueTask ConfigureAsync( + IRoboticsBuildContext context, + CancellationToken cancellationToken) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + m_manager = context.Manager; + s_cells.Remove(context.Manager); + s_cells.Add(context.Manager, this); + await MaterialiseOpenUsdFacilityAsync(cancellationToken).ConfigureAwait(false); + await MaterialiseRobotCellAsync(context, cancellationToken).ConfigureAwait(false); + Configure(context.Nodes); + m_logger.RoboticsAddressSpaceReady(m_axes.Count + m_robots.Count + 1); + } + + + internal static RobotCell GetForManager(AsyncCustomNodeManager manager) + { + if (manager == null) + { + throw new ArgumentNullException(nameof(manager)); + } + + return s_cells.TryGetValue(manager, out RobotCell? cell) + ? cell + : throw new InvalidOperationException("RobotCell has not configured this manager."); + } + + internal sealed class AxisRuntime + { + public BaseDataVariableState Position = null!; + public double Home; + public double Min; + public double Max; + public double PhaseSeconds; + public int Index; + } + + internal sealed class RobotRuntime + { + public string SourceId = string.Empty; + public string PrimPath = string.Empty; + public MotionDeviceState Robot = null!; + public OpenUsdRepresentationState Representation = null!; + } + + private NodeId RoboticsType(uint id) + { + return NodeId.Create(id, Opc.Ua.Robotics.Namespaces.Robotics, Server.NamespaceUris); + } + + private async ValueTask MaterialiseRobotCellAsync( + IRoboticsBuildContext context, + CancellationToken cancellationToken) + { + if (m_cellStage == null) + { + return; + } + + try + { + ushort ns = context.InstanceNamespaceIndex; + ushort usdNs = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); + OpenUsdRepresentationState? cellRep = null; + var robotReps = new List(); + + await context.AddMotionDeviceSystemAsync( + "RobotCell", + system => + { + system.WithComponentName("RobotCell"); + cellRep = AttachRepresentation(system.State, CellPrimPath, usdNs); + m_robotCell = system.State; + + IControllerBuilder controller = system.AddController( + "Controller_C1", + ConfigureController); + ISafetyStateBuilder safety = system.AddSafetyState( + "Safety", + ConfigureSafetyState); + + foreach ((string BrowseName, string PrimPath, bool HasTool, double PhaseSeconds) robot + in s_robots) + { + IMotionDeviceBuilder motionDevice = system.AddMotionDevice( + robot.BrowseName, + builder => BuildRobot(builder, robot, ns, usdNs, robotReps)); + controller.Controls(motionDevice).UsesSafetyState(safety); + } + + CreateComponentBinding(cellRep, usdNs, "RobotsAggregation", + new Guid("a1b2c3d4-0001-4a10-9c01-100000000001"), + OpenUsdCardinalityEnum.Many, OpenUsdCompositionArcEnum.Reference, + RobotsScopePrimPath, assetReference: "@robot.usda@", + componentTypeDefinition: RoboticsType(MotionDeviceTypeId)); + + CreateBinding(cellRep, usdNs, "EmergencyStopBeacon", + new Guid("a1b2c3d4-0002-4a10-9c01-100000000002"), + m_estopVar!.NodeId, "/Cell/SafetyBeacon", "visibility", "token", + OpenUsdRenderTargetKindEnum.Visibility, 1.0, + bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType, + alarmAspect: OpenUsdAlarmAspectEnum.ActiveState); + + CreateBinding(cellRep, usdNs, "SpeedOverrideCommand", + new Guid("a1b2c3d4-0003-4a10-9c01-100000000003"), + NodeId.Null, "/Cell", "inputs:speedOverride", "double", + kind: null, 1.0, + bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdCommandBindingType, + signalRole: OpenUsdSignalRoleEnum.Controllable, + commandTargetNodeId: m_speedOverrideVar!.NodeId, + commandTriggerPropertyName: "inputs:speedOverride"); + }, + cancellationToken).ConfigureAwait(false); + + if (cellRep == null || m_estopVar == null || m_speedOverrideVar == null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "RobotCell was not fully materialised."); + } + + OrganiseRepresentation(cellRep); + foreach (OpenUsdRepresentationState robotRep in robotReps) + { + OrganiseRepresentation(robotRep); + } + foreach (OpenUsdRepresentationState axisRep in m_axisReps) + { + OrganiseRepresentation(axisRep); + } + + Manager.ModelChangeEmissionEnabled = true; + _ = RunDynamicToolAsync(ns, usdNs); + + m_logger.MaterialisedRobotCell( + s_robots.Length, + m_axes.Count, + 1 + robotReps.Count + m_axisReps.Count); + } + catch (Exception ex) + { + m_logger.RobotCellFailed(ex); + } + } + + private void ConfigureController(IControllerBuilder controller) + { + controller.WithComponentName("Controller_C1") + .WithCurrentUser(user => user.WithLevel("Operator").WithName("Operator")) + .AddSoftware("ControllerSoftware"); + controller.AddTaskControl( + "TaskControl_C1", + taskControl => taskControl + .WithComponentName("TaskControl_C1") + .WithTaskProgramLoaded(false) + .WithTaskProgramName(string.Empty) + .AddTaskControlOperation()); + } + + private void ConfigureSafetyState(ISafetyStateBuilder safety) + { + safety.WithComponentName("Safety") + .WithEmergencyStop(false) + .AddEmergencyStop("EmergencyStop", "EmergencyStop", stop => stop.WithActive(false)); + safety.Configure((state, ctx) => + { + m_estopVar = FindRequiredChild( + state.ParameterSet!, + "EmergencyStop"); + }); + } + + private void BuildRobot( + IMotionDeviceBuilder builder, + (string BrowseName, string PrimPath, bool HasTool, double PhaseSeconds) robot, + ushort ns, + ushort usdNs, + List robotReps) + { + builder.WithComponentName(robot.BrowseName) + .WithCategory(MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT) + .WithSpeedOverride(100.0); + if (robot.HasTool) + { + builder.BindSpeedOverrideWrite(WriteSpeedOverrideAsync); + } + + OpenUsdRepresentationState? robotRep = null; + IPowerTrainBuilder powerTrain = builder.AddPowerTrain( + $"{robot.BrowseName}_PowerTrain", + pt => pt.WithComponentName($"{robot.BrowseName}_PowerTrain") + .AddMotor($"{robot.BrowseName}_Motor", motor => motor.WithMotorTemperature(25.0))); + + for (int i = 0; i < s_axisTemplate.Length; i++) + { + IAxisBuilder axis = builder.AddAxis( + s_axisTemplate[i].Name, + axisBuilder => BuildAxis(axisBuilder, robot, s_axisTemplate[i], i, usdNs)); + axis.Requires(powerTrain); + powerTrain.Moves(axis); + } + + builder.Configure((state, ctx) => + { + if (robot.HasTool) + { + m_r1NodeId = state.NodeId; + m_speedOverrideVar = FindRequiredChild( + state.ParameterSet!, + "SpeedOverride"); + m_speedOverrideVar.OnReadUserAccessLevel = OnReadCommandTargetUserAccessLevel; + } + + robotRep = AttachRepresentation(state, robot.PrimPath, usdNs); + m_robots.Add(new RobotRuntime + { + SourceId = robot.BrowseName, + PrimPath = robot.PrimPath, + Robot = state, + Representation = robotRep + }); + robotReps.Add(robotRep); + }); + + if (robotRep == null) + { + return; + } + + CreateComponentBinding(robotRep, usdNs, "AxesAggregation", + GuidFor(robot.BrowseName + ":axes"), + OpenUsdCardinalityEnum.Many, OpenUsdCompositionArcEnum.Child, + robot.PrimPath, componentTypeDefinition: RoboticsType(AxisTypeId)); + + CreateBinding(robotRep, usdNs, "EmergencyStopWarning", + GuidFor(robot.BrowseName + ":warning"), + m_estopVar!.NodeId, robot.PrimPath + "/Warning", "visibility", "token", + OpenUsdRenderTargetKindEnum.Visibility, 1.0, + bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType, + alarmAspect: OpenUsdAlarmAspectEnum.ActiveState); + + if (robot.HasTool) + { + CreateComponentBinding(robotRep, usdNs, "GripperTool", + GuidFor(robot.BrowseName + ":tool"), + OpenUsdCardinalityEnum.One, OpenUsdCompositionArcEnum.Reference, + robot.PrimPath + ToolSuffix, assetReference: "@tool.usda@", + dynamic: true, changeEventSource: Opc.Ua.ObjectIds.Server, + componentTypeDefinition: Opc.Ua.ObjectTypeIds.BaseObjectType); + } + } + + private void BuildAxis( + IAxisBuilder builder, + (string BrowseName, string PrimPath, bool HasTool, double PhaseSeconds) robot, + (string Name, string LinkPrimPath, string RotateOp, double Home, double Min, double Max) axis, + int index, + ushort usdNs) + { + builder.WithMotionProfile(AxisMotionProfileEnumeration.ROTARY) + .WithActualPosition(axis.Home); + builder.Configure((state, ctx) => + { + BaseDataVariableState position = FindRequiredChild( + state.ParameterSet!, + "ActualPosition"); + string linkPrim = robot.PrimPath + "/" + axis.LinkPrimPath; + OpenUsdRepresentationState axisRep = AttachRepresentation(state, linkPrim, usdNs); + + CreateBinding(axisRep, usdNs, "Articulation", + GuidFor($"{robot.BrowseName}:{axis.Name}:articulation"), + position.NodeId, linkPrim, axis.RotateOp, "double", + OpenUsdRenderTargetKindEnum.Rotation, 1.0, + signalRole: OpenUsdSignalRoleEnum.Observable, + sourceSemanticId: "0173-1#02-BAF564#005"); + + m_axisReps.Add(axisRep); + m_axes.Add(new AxisRuntime + { + Position = position, + Home = axis.Home, + Min = axis.Min, + Max = axis.Max, + PhaseSeconds = robot.PhaseSeconds, + Index = index + }); + }); + } + + private T FindRequiredChild(NodeState parent, string browseName) + where T : BaseInstanceState + { + if (parent == null) + { + throw new ArgumentNullException(nameof(parent)); + } + + var children = new List(); + parent.GetChildren(SystemContext, children); + for (int i = 0; i < children.Count; i++) + { + if (children[i] is T typed && + string.Equals(children[i].BrowseName.Name, browseName, StringComparison.Ordinal)) + { + return typed; + } + } + + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Child '{0}' was not found below '{1}'.", + browseName, + parent.BrowseName); + } + + private void AssignInstanceSubtree(BaseInstanceState node, NodeState referenceRoot) + { + NodeId previousNodeId = SystemContext.AssignInstanceNodeId(node); + SystemContext.AssignInstanceChildNodeIds(node, previousNodeId, referenceRoot); + } + + private ValueTask WriteSpeedOverrideAsync( + Variant value, + CancellationToken cancellationToken) + { + if (!value.TryGetValue(out double speedOverride)) + { + return new ValueTask(new ServiceResult(StatusCodes.BadTypeMismatch)); + } + + UpdateDouble(m_speedOverrideVar, speedOverride); + return new ValueTask(ServiceResult.Good); + } + + private static ServiceResult OnReadCommandTargetUserAccessLevel( + ISystemContext context, + NodeState node, + ref byte value) + { + value = IsAuthenticatedSession(context) + ? AccessLevels.CurrentReadOrWrite + : AccessLevels.CurrentRead; + return ServiceResult.Good; + } + + private static bool IsAuthenticatedSession(ISystemContext context) + { + IUserIdentity? identity = (context as ISessionSystemContext)?.UserIdentity; + if (identity == null || identity.TokenType == UserTokenType.Anonymous) + { + return false; + } + ArrayOf roles = identity.GrantedRoleIds; + for (int i = 0; i < roles.Count; i++) + { + if (roles[i] != Opc.Ua.ObjectIds.WellKnownRole_Anonymous) + { + return true; + } + } + return false; + } + + private async Task RunDynamicToolAsync(ushort ns, ushort usdNs) + { + try + { + if (m_r1NodeId.IsNull) + { + return; + } + await Task.Delay(3000).ConfigureAwait(false); + while (!m_r1NodeId.IsNull) + { + NodeId toolId = await AddMountedToolAsync(ns, usdNs).ConfigureAwait(false); + if (toolId.IsNull) + { + return; + } + m_logger.GripperToolMounted(); + await Task.Delay(12000).ConfigureAwait(false); + _ = await Manager.DeleteNodeAsync(SystemContext, toolId).ConfigureAwait(false); + m_logger.DetachedGripperTool(toolId); + await Task.Delay(6000).ConfigureAwait(false); + } + } + catch (Exception ex) + { + m_logger.DynamicToolFailed(ex); + } + } + + private async Task AddMountedToolAsync(ushort ns, ushort usdNs) + { + if (m_r1NodeId.IsNull) + { + return NodeId.Null; + } + var tool = new BaseObjectState(null) + { + SymbolicName = "MountedTool", + BrowseName = new QualifiedName("MountedTool", ns), + DisplayName = new LocalizedText("MountedTool"), + TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType + }; + + OpenUsdRepresentationState rep = SystemContext.CreateInstanceOfOpenUsdRepresentationType( + tool, + new QualifiedName("OpenUsdRepresentation", usdNs)); + rep.ReferenceTypeId = ReferenceTypeIds.HasComponent; + tool.AddChild(rep); + rep.CreateOrReplaceStage(SystemContext, null!).Value = m_cellStage!.NodeId; + rep.CreateOrReplacePrimPath(SystemContext, null!).Value = s_robots[0].PrimPath + ToolSuffix; + + NodeId newId = await Manager.AddNodeAsync( + SystemContext, + m_r1NodeId, + tool, + CancellationToken.None).ConfigureAwait(false); + m_logger.AttachedGripperTool(newId); + return newId; + } + + internal async ValueTask ConfigurePositioningAsync(PositioningServerContext context) + { + if (m_robotCell == null) + { + throw new ServiceResultException( + StatusCodes.BadInvalidState, + "RobotCell must exist before Positioning is configured."); + } + + MobileRobotPositionProvider? provider = null; + for (int i = 0; i < context.GeoLocationProviders.Count; i++) + { + if (context.GeoLocationProviders[i] is MobileRobotPositionProvider candidate) + { + provider = candidate; + break; + } + } + if (provider == null) + { + throw new ServiceResultException( + StatusCodes.BadConfigurationError, + "MobileRobotPositionProvider is not registered."); + } + + PositioningAddressSpaceBuilder builder = context.AddressSpace; + ushort rslNamespaceIndex = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.Rsl.Namespaces.RSL); + ushort gposNamespaceIndex = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.Gpos.Namespaces.GPOS); + ushort usdNamespaceIndex = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); + EUInformation metres = new("m", "metre", "http://www.opcfoundation.org/UA/units/un/cefact"); + EUInformation degrees = new("deg", "degree", "http://www.opcfoundation.org/UA/units/un/cefact"); + + SpatialObjectsListState list = builder.CreateSpatialObjectsList( + m_robotCell, + new QualifiedName("RobotCellFrames", rslNamespaceIndex), + "RobotCell", + CreateZeroFrame(), + metres, + degrees); + await builder.RegisterAsync(list, context.CancellationToken).ConfigureAwait(false); + + ZoneState zone = builder.CreateZone( + new QualifiedName("RobotCellZone", gposNamespaceIndex), + RobotPositioningScenario.ZoneId, + provider.Scenario.GroundControlPoints); + await builder.RegisterAsync(zone, context.CancellationToken).ConfigureAwait(false); + + foreach (RobotRuntime runtime in m_robots) + { + await ConfigureRobotPositioningAsync( + context, + builder, + provider, + runtime, + list, + zone, + metres, + degrees, + rslNamespaceIndex, + gposNamespaceIndex, + usdNamespaceIndex).ConfigureAwait(false); + } + + m_logger.PositioningConfigured(m_robots.Count); + } + + private async ValueTask ConfigureRobotPositioningAsync( + PositioningServerContext context, + PositioningAddressSpaceBuilder builder, + MobileRobotPositionProvider provider, + RobotRuntime runtime, + SpatialObjectsListState list, + ZoneState zone, + EUInformation metres, + EUInformation degrees, + ushort rslNamespaceIndex, + ushort gposNamespaceIndex, + ushort usdNamespaceIndex) + { + GeoLocationSample initial = await provider.ReadAsync( + runtime.SourceId, + context.CancellationToken).ConfigureAwait(false); + GeoPosition initialPosition = initial.Position!.Value; + GeoOrientation initialOrientation = initial.Orientation!.Value; + ThreeDCartesianCoordinates localPosition = + provider.Scenario.Fit.GlobalToLocal( + ToGeographic(initialPosition), + AngleUnit.Degrees); + var localFrame = new ThreeDFrame + { + CartesianCoordinates = localPosition, + Orientation = new ThreeDOrientation + { + A = initialOrientation.A, + B = initialOrientation.B, + C = initialOrientation.C + } + }; + + SpatialObjectState spatialObject = builder.AttachSpatialObject( + runtime.Robot, + list, + new QualifiedName("SpatialObject", rslNamespaceIndex), + runtime.SourceId, + localFrame, + metres, + degrees); + var positionFrame = (CartesianFrameAngleOrientationState)spatialObject.PositionFrame!; + if (runtime.SourceId == "R1") + { + _ = builder.AddAttachPoint( + spatialObject, + new QualifiedName("ToolFlange", rslNamespaceIndex), + positionFrame.NodeId, + CreateZeroFrame(), + metres, + degrees); + } + await builder.RegisterAsync(spatialObject, context.CancellationToken).ConfigureAwait(false); + + GlobalLocationState globalLocation = builder.AttachGlobalLocation( + runtime.Robot, + new QualifiedName("GlobalLocation", gposNamespaceIndex), + zone.NodeId, + 4326); + globalLocation.Position!.AddElevationReference(SystemContext); + globalLocation.Position.ElevationReference!.Value = 1; + globalLocation.Orientation!.AddAngleUnit(SystemContext); + globalLocation.Orientation!.AngleUnit!.Value = degrees; + + PositioningProviderSubscription subscription = + await builder.BindGlobalLocationAsync( + globalLocation, + provider, + runtime.SourceId, + (sample, _) => + { + GeoPosition samplePosition = sample.Position!.Value; + GeoOrientation sampleOrientation = sample.Orientation!.Value; + ThreeDCartesianCoordinates local = + provider.Scenario.Fit.GlobalToLocal( + ToGeographic(samplePosition), + AngleUnit.Degrees); + builder.SetFrameValue( + positionFrame, + new ThreeDFrame + { + CartesianCoordinates = local, + Orientation = new ThreeDOrientation + { + A = sampleOrientation.A, + B = sampleOrientation.B, + C = sampleOrientation.C + } + }, + sample.StatusCode, + sample.GetEffectiveSourceTimestamp()); + return default; + }, + context.CancellationToken).ConfigureAwait(false); + m_positioningSubscriptions.Add(subscription); + await builder.RegisterAsync(globalLocation, context.CancellationToken).ConfigureAwait(false); + + OpenUsdLiveBindingState[] bindings = + [ + CreateBinding(runtime.Representation, usdNamespaceIndex, $"{runtime.SourceId}Position", + GuidFor($"{runtime.SourceId}:position"), positionFrame.Position!.NodeId, + runtime.PrimPath, "xformOp:translate", "double3", OpenUsdRenderTargetKindEnum.Translation, 1.0), + CreateBinding(runtime.Representation, usdNamespaceIndex, $"{runtime.SourceId}Orientation", + GuidFor($"{runtime.SourceId}:orientation"), positionFrame.Orientation!.NodeId, + runtime.PrimPath, "xformOp:rotateXYZ", "double3", OpenUsdRenderTargetKindEnum.Rotation, 1.0), + CreateBinding(runtime.Representation, usdNamespaceIndex, $"{runtime.SourceId}Longitude", + GuidFor($"{runtime.SourceId}:longitude"), globalLocation.Position.Longitude!.NodeId, + runtime.PrimPath, "inputs:longitude", "double", OpenUsdRenderTargetKindEnum.Custom, 1.0), + CreateBinding(runtime.Representation, usdNamespaceIndex, $"{runtime.SourceId}Latitude", + GuidFor($"{runtime.SourceId}:latitude"), globalLocation.Position.Latitude!.NodeId, + runtime.PrimPath, "inputs:latitude", "double", OpenUsdRenderTargetKindEnum.Custom, 1.0), + CreateBinding(runtime.Representation, usdNamespaceIndex, $"{runtime.SourceId}Elevation", + GuidFor($"{runtime.SourceId}:elevation"), globalLocation.Position.Elevation!.NodeId, + runtime.PrimPath, "inputs:elevation", "double", OpenUsdRenderTargetKindEnum.Custom, 1.0) + ]; + + foreach (OpenUsdLiveBindingState binding in bindings) + { + runtime.Representation.RemoveChild(binding); + _ = await Manager.AddNodeAsync( + SystemContext, + runtime.Representation.NodeId, + binding, + context.CancellationToken).ConfigureAwait(false); + } + } + + private static ThreeDFrame CreateZeroFrame() + { + return new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates(), + Orientation = new ThreeDOrientation() + }; + } + + /// + /// Rebuilds the OPC 10000-211 geographic coordinate a provider position + /// describes, so the scenario's fit can project it back into the cell's + /// local frame. + /// + private static S3DGeographicCoordinateDataType ToGeographic(GeoPosition position) + { + return new S3DGeographicCoordinateDataType + { + EncodingMask = position.Height.HasValue + ? (uint)S3DGeographicCoordinateDataTypeFields.Elevation + : 0, + Latitude = position.Latitude, + Longitude = position.Longitude, + Elevation = position.Height ?? 0.0 + }; + } + + partial void Configure(INodeManagerBuilder builder); + + /// + public void Dispose() + { + foreach (PositioningProviderSubscription subscription in m_positioningSubscriptions) + { + subscription.Dispose(); + } + m_positioningSubscriptions.Clear(); + } + + private static Guid GuidFor(string key) + { + byte[] hash; +#pragma warning disable CA1850 // Prefer static HashData (net48/netstandard2.0 compatibility) + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes("robotics:" + key)); + } +#pragma warning restore CA1850 + byte[] g = new byte[16]; + Array.Copy(hash, g, 16); + return new Guid(g); + } + } + + internal static partial class RobotCellLog + { + [LoggerMessage(EventId = MinimalRobotServerEventIds.RobotCell + 1, + Level = LogLevel.Information, + Message = "Materialised RobotCell ({RobotCount} robots, {AxisCount} axes, {RepCount} representations).")] + public static partial void MaterialisedRobotCell( + this ILogger logger, int robotCount, int axisCount, int repCount); + + [LoggerMessage(EventId = MinimalRobotServerEventIds.RobotCell + 2, + Level = LogLevel.Information, + Message = "Dynamic composition: attached gripper tool (NodeId={NodeId}); model-change emitted.")] + public static partial void AttachedGripperTool(this ILogger logger, NodeId nodeId); + + [LoggerMessage(EventId = MinimalRobotServerEventIds.RobotCell + 3, + Level = LogLevel.Information, + Message = "Configured RSL/GPOS positioning for {RobotCount} robots.")] + public static partial void PositioningConfigured(this ILogger logger, int robotCount); + + [LoggerMessage(EventId = MinimalRobotServerEventIds.RobotCell + 5, + Level = LogLevel.Error, + Message = "Failed to materialise RobotCell.")] + public static partial void RobotCellFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = MinimalRobotServerEventIds.RobotCell + 6, + Level = LogLevel.Information, + Message = "Dynamic composition mounted the gripper tool on R1.")] + public static partial void GripperToolMounted(this ILogger logger); + + [LoggerMessage(EventId = MinimalRobotServerEventIds.RobotCell + 7, + Level = LogLevel.Warning, + Message = "Dynamic tool composition failed.")] + public static partial void DynamicToolFailed(this ILogger logger, Exception exception); + + [LoggerMessage(EventId = MinimalRobotServerEventIds.RobotCell + 8, + Level = LogLevel.Information, + Message = "Dynamic composition: detached gripper tool (NodeId={NodeId}); model-change emitted.")] + public static partial void DetachedGripperTool(this ILogger logger, NodeId nodeId); + + [LoggerMessage(EventId = MinimalRobotServerEventIds.RobotCell + 9, + Level = LogLevel.Information, + Message = "RobotCell configurator: address space ready ({NodeCount} runtime nodes).")] + public static partial void RoboticsAddressSpaceReady(this ILogger logger, int nodeCount); + } +} diff --git a/samples/MinimalRobotServer/RobotPositioningScenario.cs b/samples/MinimalRobotServer/RobotPositioningScenario.cs new file mode 100644 index 0000000000..d52af159f0 --- /dev/null +++ b/samples/MinimalRobotServer/RobotPositioningScenario.cs @@ -0,0 +1,90 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using Opc.Ua; +using Opc.Ua.Gpos; +using Opc.Ua.Positioning; + +namespace Robotics +{ + /// + /// Stable Zone control points and fitted local/global transform for the sample. + /// + public sealed class RobotPositioningScenario + { + public const string ZoneId = "RobotCellZone"; + + public RobotPositioningScenario() + { + var origin = new S3DGeographicCoordinateDataType + { + EncodingMask = + (uint)S3DGeographicCoordinateDataTypeFields.Elevation, + Longitude = 11.576124, + Latitude = 48.137154, + Elevation = 520.0 + }; + var plane = new LocalTangentPlane(origin, AngleUnit.Degrees); + var points = new List(); + foreach (ThreeDCartesianCoordinates local in new[] + { + new ThreeDCartesianCoordinates { X = -5.0, Y = -5.0, Z = 0.0 }, + new ThreeDCartesianCoordinates { X = 5.0, Y = -5.0, Z = 0.0 }, + new ThreeDCartesianCoordinates { X = 5.0, Y = 5.0, Z = 0.0 }, + new ThreeDCartesianCoordinates { X = -5.0, Y = 5.0, Z = 0.0 }, + new ThreeDCartesianCoordinates { X = 0.0, Y = 0.0, Z = 2.0 } + }) + { + points.Add(new GroundControlPointDataType + { + LocalPosition = local, + GlobalPosition = plane.EnuToGeographic( + local, + AngleUnit.Degrees, + includeElevation: true) + }); + } + + GroundControlPoints = + ArrayOf.Create(points.ToArray()); + Fit = new GroundControlPointFitter().Fit( + GroundControlPoints, + new GroundControlPointFitOptions + { + Mode = GroundControlPointFitMode.Rigid, + AngleUnit = AngleUnit.Degrees + }); + } + + public ArrayOf GroundControlPoints { get; } + + public GroundControlPointFitResult Fit { get; } + } +} diff --git a/samples/PumpDeviceIntegrationServer/Assets/Plant.usda b/samples/PumpDeviceIntegrationServer/Assets/Plant.usda new file mode 100644 index 0000000000..631e7921d3 --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/Assets/Plant.usda @@ -0,0 +1,1200 @@ +#usda 1.0 +( + doc = """Base pump asset for the OPC UA - OpenUSD Bindings pump end-to-end. + + Authored by the '3D / USD asset author' actor (Annex B). It carries only + geometry and materials; it contains NO live values. The live OPC UA values + are composed on top by `live.usda` (see `stage.usda`), which a connector + authors at runtime. The prim paths and attribute names here are the contract + the OpenUSD bindings target: + + /Plant/Pumps/P101/Impeller xformOp:rotateZ (MassFlow -> Rotation) + /Plant/Pumps/P101/Body primvars:displayColor(BearingTemperature -> DisplayColor) + /Plant/Pumps/P101/Body/Mat/Surface.inputs:diffuseColor + (BearingTemperature -> DisplayColor) + /Plant/Pumps/P101/StatusLight/Mat/Surface.inputs:emissiveColor + (DifferentialPressure -> EmissiveColor) + /Plant/Pumps/P101/StatusLight.visibility (alarm ActiveState -> Visibility) + /Plant/Pumps/P101/Impeller.inputs:speedSetpoint (opt-in command intent -> UA write) + + MODEL + P101 is a horizontal long-coupled end-suction centrifugal pump built to + EN 733 (formerly DIN 24255), size 65-200 - the geometry follows the published + dimensions of the Grundfos NK 65-200 / KSB Etanorm 65-200 family, driven by an + IEC 160M motor, both bolted to a fabricated steel baseplate. Key dimensions + (metres): baseplate 1.80 x 0.46 x 0.12; shaft centreline 0.160 above the + baseplate (the IEC 160M frame number *is* the shaft height in mm); volute + casing 0.355 outer diameter x 0.110 wide; DN80 axial suction flange (OD 0.200) + and DN65 vertical discharge flange (OD 0.185) per EN 1092-2 PN16; 0.198 + impeller with six backward-curved vanes; IEC 160M motor frame 0.254 outer + diameter x 0.615 long. Livery is KSB signal blue (RAL 5005) for the wetted + castings and RAL 7035 light grey for the motor. + + Two deliberate departures from a real pump, both so the twin can be *seen*: + * The suction pipe is drawn as a short stub, leaving the casing eye open - + a cutaway, the way a trade-show display pump is presented - so the + rotating impeller is visible. + * The coupling guard is a cage (end rings plus longitudinal bars) rather + than a solid sheet-metal barrel, so the rotating coupling is visible. + + The shaft of a real pump is horizontal, but the binding contract fixes the + driven operation as `xformOp:rotateZ`. `Impeller` therefore carries a static + `xformOp:rotateY = 90` *ahead of* `xformOp:rotateZ` in `xformOpOrder`, which + lays its local Z along the world shaft axis (X). The contract operation still + spins the shaft, and it turns the impeller and the coupling together because + both hang off the same rotating prim - as they do on the real machine. + """ + defaultPrim = "Plant" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Plant" +{ + def Scope "Looks" + { + def Material "PumpBlue" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.02, 0.24, 0.60) + float inputs:metallic = 0.1 + float inputs:roughness = 0.45 + token outputs:surface + } + } + + def Material "MotorGrey" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.60, 0.61, 0.60) + float inputs:metallic = 0.2 + float inputs:roughness = 0.50 + token outputs:surface + } + } + + def Material "Galvanised" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.68, 0.70, 0.72) + float inputs:metallic = 0.8 + float inputs:roughness = 0.35 + token outputs:surface + } + } + + def Material "DarkSteel" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.16, 0.17, 0.19) + float inputs:metallic = 0.6 + float inputs:roughness = 0.55 + token outputs:surface + } + } + + def Material "Bronze" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.72, 0.53, 0.24) + float inputs:metallic = 0.9 + float inputs:roughness = 0.30 + token outputs:surface + } + } + } + + def DistantLight "KeyLight" + { + float inputs:angle = 1.0 + float inputs:intensity = 3200 + color3f inputs:color = (1.0, 0.98, 0.94) + double3 xformOp:rotateXYZ = (-48, 0, 34) + uniform token[] xformOpOrder = ["xformOp:rotateXYZ"] + } + + def DistantLight "FillLight" + { + float inputs:angle = 2.0 + float inputs:intensity = 1100 + color3f inputs:color = (0.82, 0.88, 1.0) + double3 xformOp:rotateXYZ = (-24, 0, -122) + uniform token[] xformOpOrder = ["xformOp:rotateXYZ"] + } + + def DistantLight "AmbientLight" + { + float inputs:angle = 6.0 + float inputs:intensity = 420 + color3f inputs:color = (0.72, 0.78, 0.88) + double3 xformOp:rotateXYZ = (-78, 0, 180) + uniform token[] xformOpOrder = ["xformOp:rotateXYZ"] + } + + # Three-quarter view along the pump line. A horizontal end-suction pump is + # all profile, so an overhead camera shows little but baseplate; this one + # looks down the line from the front-right and slightly above, which reads + # the volute, the coupling guard and the motor. Hosts start on it by prim + # path (the connector's --camera option). + def Camera "HeroCamera" + { + token projection = "perspective" + float focalLength = 24 + float horizontalAperture = 20.955 + float verticalAperture = 13.97 + float2 clippingRange = (0.1, 200) + double3 xformOp:translate = (6.0, -5.94, 3.77) + double3 xformOp:rotateXYZ = (70, 0, 41) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ"] + } + + def Mesh "Floor" + { + int[] faceVertexCounts = [4] + int[] faceVertexIndices = [0, 1, 2, 3] + point3f[] points = [ + (-4, -3.4, 0), (7, -3.4, 0), (7, 4.2, 0), (-4, 4.2, 0) + ] + normal3f[] primvars:normals = [ + (0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1) + ] ( + interpolation = "faceVarying" + ) + color3f[] primvars:displayColor = [(0.34, 0.34, 0.35)] + } + + def Scope "Pumps" + { + def Xform "P101" ( + kind = "component" + ) + { + double3 xformOp:translate = (0, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Xform "Baseplate" + { + def Cube "Plate" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.20, 0.21, 0.23)] + rel material:binding = + double3 xformOp:translate = (-0.08, 0, 0.06) + double3 xformOp:scale = (1.80, 0.46, 0.12) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "PumpPad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] + rel material:binding = + double3 xformOp:translate = (-0.62, 0, 0.135) + double3 xformOp:scale = (0.44, 0.40, 0.03) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "MotorRailA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] + rel material:binding = + double3 xformOp:translate = (0.31, -0.105, 0.1365) + double3 xformOp:scale = (0.62, 0.07, 0.033) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "MotorRailB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] + rel material:binding = + double3 xformOp:translate = (0.31, 0.105, 0.1365) + double3 xformOp:scale = (0.62, 0.07, 0.033) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "LiftingLugA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.24, 0.25, 0.27)] + rel material:binding = + double3 xformOp:translate = (-0.86, -0.24, 0.10) + double3 xformOp:scale = (0.10, 0.014, 0.05) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "LiftingLugB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.24, 0.25, 0.27)] + rel material:binding = + double3 xformOp:translate = (0.70, 0.24, 0.10) + double3 xformOp:scale = (0.10, 0.014, 0.05) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Cylinder "Body" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.110 + double radius = 0.1775 + color3f[] primvars:displayColor = [(0.02, 0.24, 0.60)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.02, 0.24, 0.60) + float inputs:metallic = 0.1 + float inputs:roughness = 0.45 + token outputs:surface + } + } + } + + def Xform "Casing" + { + def Cylinder "VoluteShoulder" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.070 + double radius = 0.196 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.014, 0.293) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "Foot" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.1875) + double3 xformOp:scale = (0.11, 0.30, 0.135) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FootPad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.02, 0.20, 0.50)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.159) + double3 xformOp:scale = (0.16, 0.36, 0.018) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cylinder "DrainPlug" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.022 + double radius = 0.016 + color3f[] primvars:displayColor = [(0.72, 0.53, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.700, 0, 0.115) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Xform "Suction" + { + def Cylinder "Flange" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.020 + double radius = 0.100 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Neck" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.055 + double radius = 0.051 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.822, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltTop" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0, 0.360) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltBottom" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0, 0.200) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, -0.080, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0.080, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Xform "Discharge" + { + def Cylinder "Neck" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.150 + double radius = 0.043 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.455) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Flange" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.018 + double radius = 0.0925 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltFront" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.5725, 0, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltBack" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.7175, 0, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.0725, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.645, -0.0725, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Gauge" + { + def Cylinder "Stem" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.055 + double radius = 0.007 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.070, 0.470) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Dial" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.022 + double radius = 0.041 + color3f[] primvars:displayColor = [(0.90, 0.90, 0.88)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.108, 0.470) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + } + + def Xform "PowerEnd" + { + def Cylinder "SealHousing" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.060 + double radius = 0.046 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.560, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Lantern" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.040 + double radius = 0.058 + color3f[] primvars:displayColor = [(0.02, 0.20, 0.50)] + rel material:binding = + double3 xformOp:translate = (-0.510, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Bracket" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.220 + double radius = 0.0625 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.380, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "BracketFoot" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.02, 0.20, 0.50)] + rel material:binding = + double3 xformOp:translate = (-0.360, 0, 0.1875) + double3 xformOp:scale = (0.16, 0.20, 0.105) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cylinder "SightGlass" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.020 + double radius = 0.015 + color3f[] primvars:displayColor = [(0.85, 0.86, 0.62)] + rel material:binding = + double3 xformOp:translate = (-0.330, 0.064, 0.245) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Cylinder "Bearing" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.036 + double radius = 0.052 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (-0.300, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Impeller" + { + double3 xformOp:translate = (0, 0, 0.28) + double xformOp:rotateY = 90 + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateY", "xformOp:rotateZ" + ] + + custom double inputs:speedSetpoint = 0 + + def Cylinder "Shaft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.760 + double radius = 0.019 + color3f[] primvars:displayColor = [(0.62, 0.63, 0.65)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.300) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Shroud" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.012 + double radius = 0.099 + color3f[] primvars:displayColor = [(0.66, 0.48, 0.22)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.664) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Hub" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.034 + double radius = 0.030 + color3f[] primvars:displayColor = [(0.66, 0.48, 0.22)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.645) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "VaneA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (0.052, 0.014, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 22) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (0.014, 0.052, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 82) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneC" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.038, 0.038, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 142) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneD" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.052, -0.014, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 202) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneE" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.014, -0.052, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 262) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneF" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (0.038, -0.038, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 322) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cylinder "CouplingHubPump" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.052 + double radius = 0.070 + color3f[] primvars:displayColor = [(0.34, 0.35, 0.37)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.196) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "CouplingSpider" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.014 + double radius = 0.058 + color3f[] primvars:displayColor = [(0.92, 0.66, 0.06)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.163) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "CouplingHubMotor" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.052 + double radius = 0.070 + color3f[] primvars:displayColor = [(0.34, 0.35, 0.37)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.130) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "CouplingKey" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.94, 0.94, 0.95)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.168) + double3 xformOp:scale = (0.020, 0.140, 0.010) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Xform "CouplingGuard" + { + def Cylinder "RingPump" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.012 + double radius = 0.110 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.264, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "RingMotor" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.012 + double radius = 0.110 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.006, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "BarTop" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, 0, 0.388) + double3 xformOp:scale = (0.264, 0.030, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BarBottom" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, 0, 0.172) + double3 xformOp:scale = (0.264, 0.030, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BarLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, -0.108, 0.28) + double3 xformOp:scale = (0.264, 0.008, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BarRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, 0.108, 0.28) + double3 xformOp:scale = (0.264, 0.008, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Xform "Motor" + { + def Cylinder "Frame" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.615 + double radius = 0.127 + color3f[] primvars:displayColor = [(0.60, 0.61, 0.60)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "DriveEndShield" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.050 + double radius = 0.132 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.030, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "NonDriveEndShield" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.050 + double radius = 0.132 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.595, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "FanCowl" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.080 + double radius = 0.140 + color3f[] primvars:displayColor = [(0.48, 0.49, 0.50)] + rel material:binding = + double3 xformOp:translate = (0.660, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "FinTop" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0, 0.412) + double3 xformOp:scale = (0.615, 0.030, 0.024) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FinUpperLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, -0.098, 0.372) + double3 xformOp:rotateXYZ = (40, 0, 0) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "FinUpperRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0.098, 0.372) + double3 xformOp:rotateXYZ = (-40, 0, 0) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "FinLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, -0.132, 0.28) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FinRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0.132, 0.28) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "TerminalBox" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.52, 0.53, 0.52)] + rel material:binding = + double3 xformOp:translate = (0.230, 0, 0.452) + double3 xformOp:scale = (0.120, 0.120, 0.080) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cylinder "CableGland" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.040 + double radius = 0.016 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0.230, -0.078, 0.440) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "EyeBolt" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.046 + double radius = 0.012 + color3f[] primvars:displayColor = [(0.62, 0.63, 0.65)] + rel material:binding = + double3 xformOp:translate = (0.470, 0, 0.428) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "Nameplate" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.86, 0.87, 0.88)] + rel material:binding = + double3 xformOp:translate = (0.150, -0.129, 0.318) + double3 xformOp:scale = (0.090, 0.004, 0.055) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FootLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.310, -0.105, 0.179) + double3 xformOp:scale = (0.300, 0.044, 0.052) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FootRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.310, 0.105, 0.179) + double3 xformOp:scale = (0.300, 0.044, 0.052) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + # Beacon hardware. Permanently mounted on a real skid, so it sits + # outside the alarm-gated prim below - only the lamp itself is gated. + def Xform "Beacon" + { + double3 xformOp:translate = (0.10, -0.32, 0.12) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Cylinder "Post" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.520 + double radius = 0.016 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.260) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Housing" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.050 + double radius = 0.050 + color3f[] primvars:displayColor = [(0.20, 0.21, 0.23)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.545) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + # The lamp itself is permanently mounted; its emissive colour is + # driven by discharge pressure, so its glow tracks the duty point + # and shows at a glance that the pump is running. It borrows the + # material the emissive binding targets under StatusLight. + def Sphere "Lamp" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double radius = 0.050 + color3f[] primvars:displayColor = [(0.90, 0.20, 0.16)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.585) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Xform "StatusLight" + { + double3 xformOp:translate = (0.10, -0.32, 0.12) + uniform token[] xformOpOrder = ["xformOp:translate"] + + token visibility = "inherited" + + # Alarm halo - only this is gated by the supervision alarm, so a + # cleared alarm leaves the beacon hardware and lamp on show. + def Sphere "Halo" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double radius = 0.085 + color3f[] primvars:displayColor = [(1.0, 0.35, 0.10)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.585) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.1, 0.1, 0.1) + color3f inputs:emissiveColor = (0, 0, 0) + float inputs:roughness = 0.25 + token outputs:surface + } + } + } + } + } + + def Xform "Line1" + { + def Scope "Pumps" + { + def Xform "P_201" + { + double3 xformOp:translate = (0, 1.5, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "P_202" + { + double3 xformOp:translate = (0, 2.7, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "P_203" + { + double3 xformOp:translate = (0, 3.9, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Xform "RemotePump" + { + double3 xformOp:translate = (0, -1.5, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } +} diff --git a/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py b/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py new file mode 100644 index 0000000000..c9a9c0db93 --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py @@ -0,0 +1,143 @@ +"""Regenerates the referenced pump assets from the P101 master in Plant.usda. + +pump.usda and remote-pump.usda are the component assets a ProductionLine +references once per aggregated pump. They must carry the same geometry as +/Plant/Pumps/P101 so every pump in the line renders at full fidelity, but they +have to be self-contained: material paths are rewritten to a local Looks scope. + +Run from the repository root: + python samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py +""" + +import pathlib +import re + +HERE = pathlib.Path(__file__).parent +PLANT = HERE / "Plant.usda" + +# The P101 children that make up the machine itself. Beacon and StatusLight are +# plant-side signalling equipment, not part of the pump component asset. +WANTED = [ + "Baseplate", + "Body", + "Casing", + "Suction", + "Discharge", + "PowerEnd", + "Bearing", + "Impeller", + "CouplingGuard", + "Motor", +] + +# KSB signal blue (RAL 5005) -> OEM moss green (RAL 6011), so the pump federated +# from the remote server is obvious at a glance. +GREEN = { + "(0.02, 0.24, 0.60)": "(0.24, 0.36, 0.20)", + "(0.02, 0.22, 0.55)": "(0.22, 0.33, 0.18)", + "(0.02, 0.20, 0.50)": "(0.20, 0.30, 0.16)", +} + + +def block(lines, start): + """Returns the line range of a brace-delimited prim block starting at `start`.""" + depth = 0 + index = start + seen = False + while index < len(lines): + depth += lines[index].count("{") - lines[index].count("}") + if "{" in lines[index]: + seen = True + if seen and depth == 0: + return start, index + 1 + index += 1 + raise ValueError(f"unterminated block at line {start}") + + +def extract(text, name, indent): + """Extracts the named prim block at the given indent, dedented to column 4.""" + lines = text.splitlines() + pattern = re.compile(rf'^{" " * indent}def \w+ "{re.escape(name)}"') + for index, line in enumerate(lines): + if pattern.match(line): + first, last = block(lines, index) + body = lines[first:last] + trim = indent - 4 + return "\n".join(line[trim:] if line.startswith(" " * trim) else line + for line in body) + raise ValueError(f"prim {name!r} not found at indent {indent}") + + +def looks(text): + """Extracts the shared Looks scope and rehomes it under /Pump.""" + scope = extract(text, "Looks", 4) + return scope.replace(" once per aggregated pump + (spec 5.12, CompositionArc = Instance/Reference). The cross-server component + references an equivalent OEM asset (remote-pump.usda) on another server (5.14). + + Generated by generate_pump_assets.py from the /Plant/Pumps/P101 master in + Plant.usda, so every aggregated pump renders at the same fidelity as the hero + pump: an EN 733 size 65-200 end-suction centrifugal pump (Grundfos NK / + KSB Etanorm proportions) driven by an IEC 160M motor on a 1.80 x 0.46 m + baseplate, shaft centreline 0.160 m above the baseplate. Do not edit by hand - + edit P101 and re-run the generator. + + The plant-side signalling equipment (Beacon, StatusLight) is deliberately not + part of the component asset.""" + +REMOTE_DOC = """OEM pump asset served by a *different* OPC UA server, referenced + across the federation boundary by the cross-server binding + (spec 5.14). Geometrically identical to pump.usda so the composed line reads + consistently, but wearing the OEM's own livery - moss green (RAL 6011) + castings instead of KSB signal blue. + + Generated by generate_pump_assets.py from the /Plant/Pumps/P101 master in + Plant.usda. Do not edit by hand - edit P101 and re-run the generator.""" + + +def main(): + plant = PLANT.read_text(encoding="utf-8") + (HERE / "pump.usda").write_text( + build(plant, PUMP_DOC, recolour=False), encoding="utf-8", newline="\r\n") + (HERE / "remote-pump.usda").write_text( + build(plant, REMOTE_DOC, recolour=True), encoding="utf-8", newline="\r\n") + print("wrote pump.usda and remote-pump.usda") + + +if __name__ == "__main__": + main() diff --git a/samples/PumpDeviceIntegrationServer/Assets/pump.usda b/samples/PumpDeviceIntegrationServer/Assets/pump.usda new file mode 100644 index 0000000000..d39fb9c344 --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/Assets/pump.usda @@ -0,0 +1,989 @@ +#usda 1.0 +( + doc = """Reusable single-pump USD asset for the OPC UA - OpenUSD Bindings + composition demo. A ProductionLine aggregates 1..n pumps by *referencing* + (and instancing) this asset's default prim once per aggregated pump + (spec 5.12, CompositionArc = Instance/Reference). The cross-server component + references an equivalent OEM asset (remote-pump.usda) on another server (5.14). + + Generated by generate_pump_assets.py from the /Plant/Pumps/P101 master in + Plant.usda, so every aggregated pump renders at the same fidelity as the hero + pump: an EN 733 size 65-200 end-suction centrifugal pump (Grundfos NK / + KSB Etanorm proportions) driven by an IEC 160M motor on a 1.80 x 0.46 m + baseplate, shaft centreline 0.160 m above the baseplate. Do not edit by hand - + edit P101 and re-run the generator. + + The plant-side signalling equipment (Beacon, StatusLight) is deliberately not + part of the component asset.""" + defaultPrim = "Pump" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Pump" ( + kind = "component" +) +{ + def Scope "Looks" + { + def Material "PumpBlue" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.02, 0.24, 0.60) + float inputs:metallic = 0.1 + float inputs:roughness = 0.45 + token outputs:surface + } + } + + def Material "MotorGrey" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.60, 0.61, 0.60) + float inputs:metallic = 0.2 + float inputs:roughness = 0.50 + token outputs:surface + } + } + + def Material "Galvanised" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.68, 0.70, 0.72) + float inputs:metallic = 0.8 + float inputs:roughness = 0.35 + token outputs:surface + } + } + + def Material "DarkSteel" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.16, 0.17, 0.19) + float inputs:metallic = 0.6 + float inputs:roughness = 0.55 + token outputs:surface + } + } + + def Material "Bronze" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.72, 0.53, 0.24) + float inputs:metallic = 0.9 + float inputs:roughness = 0.30 + token outputs:surface + } + } + } + + def Xform "Baseplate" + { + def Cube "Plate" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.20, 0.21, 0.23)] + rel material:binding = + double3 xformOp:translate = (-0.08, 0, 0.06) + double3 xformOp:scale = (1.80, 0.46, 0.12) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "PumpPad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] + rel material:binding = + double3 xformOp:translate = (-0.62, 0, 0.135) + double3 xformOp:scale = (0.44, 0.40, 0.03) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "MotorRailA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] + rel material:binding = + double3 xformOp:translate = (0.31, -0.105, 0.1365) + double3 xformOp:scale = (0.62, 0.07, 0.033) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "MotorRailB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] + rel material:binding = + double3 xformOp:translate = (0.31, 0.105, 0.1365) + double3 xformOp:scale = (0.62, 0.07, 0.033) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "LiftingLugA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.24, 0.25, 0.27)] + rel material:binding = + double3 xformOp:translate = (-0.86, -0.24, 0.10) + double3 xformOp:scale = (0.10, 0.014, 0.05) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "LiftingLugB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.24, 0.25, 0.27)] + rel material:binding = + double3 xformOp:translate = (0.70, 0.24, 0.10) + double3 xformOp:scale = (0.10, 0.014, 0.05) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Cylinder "Body" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.110 + double radius = 0.1775 + color3f[] primvars:displayColor = [(0.02, 0.24, 0.60)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.02, 0.24, 0.60) + float inputs:metallic = 0.1 + float inputs:roughness = 0.45 + token outputs:surface + } + } + } + + def Xform "Casing" + { + def Cylinder "VoluteShoulder" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.070 + double radius = 0.196 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.014, 0.293) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "Foot" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.1875) + double3 xformOp:scale = (0.11, 0.30, 0.135) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FootPad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.02, 0.20, 0.50)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.159) + double3 xformOp:scale = (0.16, 0.36, 0.018) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cylinder "DrainPlug" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.022 + double radius = 0.016 + color3f[] primvars:displayColor = [(0.72, 0.53, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.700, 0, 0.115) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Xform "Suction" + { + def Cylinder "Flange" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.020 + double radius = 0.100 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Neck" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.055 + double radius = 0.051 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.822, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltTop" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0, 0.360) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltBottom" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0, 0.200) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, -0.080, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0.080, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Xform "Discharge" + { + def Cylinder "Neck" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.150 + double radius = 0.043 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.455) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Flange" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.018 + double radius = 0.0925 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltFront" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.5725, 0, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltBack" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.7175, 0, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.0725, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.645, -0.0725, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Gauge" + { + def Cylinder "Stem" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.055 + double radius = 0.007 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.070, 0.470) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Dial" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.022 + double radius = 0.041 + color3f[] primvars:displayColor = [(0.90, 0.90, 0.88)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.108, 0.470) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + } + + def Xform "PowerEnd" + { + def Cylinder "SealHousing" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.060 + double radius = 0.046 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.560, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Lantern" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.040 + double radius = 0.058 + color3f[] primvars:displayColor = [(0.02, 0.20, 0.50)] + rel material:binding = + double3 xformOp:translate = (-0.510, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Bracket" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.220 + double radius = 0.0625 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (-0.380, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "BracketFoot" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.02, 0.20, 0.50)] + rel material:binding = + double3 xformOp:translate = (-0.360, 0, 0.1875) + double3 xformOp:scale = (0.16, 0.20, 0.105) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cylinder "SightGlass" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.020 + double radius = 0.015 + color3f[] primvars:displayColor = [(0.85, 0.86, 0.62)] + rel material:binding = + double3 xformOp:translate = (-0.330, 0.064, 0.245) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Cylinder "Bearing" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.036 + double radius = 0.052 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (-0.300, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Impeller" + { + double3 xformOp:translate = (0, 0, 0.28) + double xformOp:rotateY = 90 + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateY", "xformOp:rotateZ" + ] + + custom double inputs:speedSetpoint = 0 + + def Cylinder "Shaft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.760 + double radius = 0.019 + color3f[] primvars:displayColor = [(0.62, 0.63, 0.65)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.300) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Shroud" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.012 + double radius = 0.099 + color3f[] primvars:displayColor = [(0.66, 0.48, 0.22)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.664) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Hub" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.034 + double radius = 0.030 + color3f[] primvars:displayColor = [(0.66, 0.48, 0.22)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.645) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "VaneA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (0.052, 0.014, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 22) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (0.014, 0.052, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 82) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneC" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.038, 0.038, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 142) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneD" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.052, -0.014, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 202) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneE" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.014, -0.052, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 262) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneF" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (0.038, -0.038, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 322) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cylinder "CouplingHubPump" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.052 + double radius = 0.070 + color3f[] primvars:displayColor = [(0.34, 0.35, 0.37)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.196) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "CouplingSpider" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.014 + double radius = 0.058 + color3f[] primvars:displayColor = [(0.92, 0.66, 0.06)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.163) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "CouplingHubMotor" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.052 + double radius = 0.070 + color3f[] primvars:displayColor = [(0.34, 0.35, 0.37)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.130) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "CouplingKey" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.94, 0.94, 0.95)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.168) + double3 xformOp:scale = (0.020, 0.140, 0.010) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Xform "CouplingGuard" + { + def Cylinder "RingPump" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.012 + double radius = 0.110 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.264, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "RingMotor" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.012 + double radius = 0.110 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.006, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "BarTop" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, 0, 0.388) + double3 xformOp:scale = (0.264, 0.030, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BarBottom" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, 0, 0.172) + double3 xformOp:scale = (0.264, 0.030, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BarLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, -0.108, 0.28) + double3 xformOp:scale = (0.264, 0.008, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BarRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, 0.108, 0.28) + double3 xformOp:scale = (0.264, 0.008, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Xform "Motor" + { + def Cylinder "Frame" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.615 + double radius = 0.127 + color3f[] primvars:displayColor = [(0.60, 0.61, 0.60)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "DriveEndShield" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.050 + double radius = 0.132 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.030, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "NonDriveEndShield" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.050 + double radius = 0.132 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.595, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "FanCowl" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.080 + double radius = 0.140 + color3f[] primvars:displayColor = [(0.48, 0.49, 0.50)] + rel material:binding = + double3 xformOp:translate = (0.660, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "FinTop" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0, 0.412) + double3 xformOp:scale = (0.615, 0.030, 0.024) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FinUpperLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, -0.098, 0.372) + double3 xformOp:rotateXYZ = (40, 0, 0) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "FinUpperRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0.098, 0.372) + double3 xformOp:rotateXYZ = (-40, 0, 0) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "FinLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, -0.132, 0.28) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FinRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0.132, 0.28) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "TerminalBox" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.52, 0.53, 0.52)] + rel material:binding = + double3 xformOp:translate = (0.230, 0, 0.452) + double3 xformOp:scale = (0.120, 0.120, 0.080) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cylinder "CableGland" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.040 + double radius = 0.016 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0.230, -0.078, 0.440) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "EyeBolt" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.046 + double radius = 0.012 + color3f[] primvars:displayColor = [(0.62, 0.63, 0.65)] + rel material:binding = + double3 xformOp:translate = (0.470, 0, 0.428) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "Nameplate" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.86, 0.87, 0.88)] + rel material:binding = + double3 xformOp:translate = (0.150, -0.129, 0.318) + double3 xformOp:scale = (0.090, 0.004, 0.055) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FootLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.310, -0.105, 0.179) + double3 xformOp:scale = (0.300, 0.044, 0.052) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FootRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.310, 0.105, 0.179) + double3 xformOp:scale = (0.300, 0.044, 0.052) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } +} diff --git a/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda b/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda new file mode 100644 index 0000000000..cf57beaa41 --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda @@ -0,0 +1,982 @@ +#usda 1.0 +( + doc = """OEM pump asset served by a *different* OPC UA server, referenced + across the federation boundary by the cross-server binding + (spec 5.14). Geometrically identical to pump.usda so the composed line reads + consistently, but wearing the OEM's own livery - moss green (RAL 6011) + castings instead of KSB signal blue. + + Generated by generate_pump_assets.py from the /Plant/Pumps/P101 master in + Plant.usda. Do not edit by hand - edit P101 and re-run the generator.""" + defaultPrim = "Pump" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Pump" ( + kind = "component" +) +{ + def Scope "Looks" + { + def Material "PumpBlue" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.24, 0.36, 0.20) + float inputs:metallic = 0.1 + float inputs:roughness = 0.45 + token outputs:surface + } + } + + def Material "MotorGrey" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.60, 0.61, 0.60) + float inputs:metallic = 0.2 + float inputs:roughness = 0.50 + token outputs:surface + } + } + + def Material "Galvanised" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.68, 0.70, 0.72) + float inputs:metallic = 0.8 + float inputs:roughness = 0.35 + token outputs:surface + } + } + + def Material "DarkSteel" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.16, 0.17, 0.19) + float inputs:metallic = 0.6 + float inputs:roughness = 0.55 + token outputs:surface + } + } + + def Material "Bronze" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.72, 0.53, 0.24) + float inputs:metallic = 0.9 + float inputs:roughness = 0.30 + token outputs:surface + } + } + } + + def Xform "Baseplate" + { + def Cube "Plate" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.20, 0.21, 0.23)] + rel material:binding = + double3 xformOp:translate = (-0.08, 0, 0.06) + double3 xformOp:scale = (1.80, 0.46, 0.12) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "PumpPad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] + rel material:binding = + double3 xformOp:translate = (-0.62, 0, 0.135) + double3 xformOp:scale = (0.44, 0.40, 0.03) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "MotorRailA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] + rel material:binding = + double3 xformOp:translate = (0.31, -0.105, 0.1365) + double3 xformOp:scale = (0.62, 0.07, 0.033) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "MotorRailB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] + rel material:binding = + double3 xformOp:translate = (0.31, 0.105, 0.1365) + double3 xformOp:scale = (0.62, 0.07, 0.033) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "LiftingLugA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.24, 0.25, 0.27)] + rel material:binding = + double3 xformOp:translate = (-0.86, -0.24, 0.10) + double3 xformOp:scale = (0.10, 0.014, 0.05) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "LiftingLugB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.24, 0.25, 0.27)] + rel material:binding = + double3 xformOp:translate = (0.70, 0.24, 0.10) + double3 xformOp:scale = (0.10, 0.014, 0.05) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Cylinder "Body" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.110 + double radius = 0.1775 + color3f[] primvars:displayColor = [(0.24, 0.36, 0.20)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.24, 0.36, 0.20) + float inputs:metallic = 0.1 + float inputs:roughness = 0.45 + token outputs:surface + } + } + } + + def Xform "Casing" + { + def Cylinder "VoluteShoulder" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.070 + double radius = 0.196 + color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.014, 0.293) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "Foot" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.1875) + double3 xformOp:scale = (0.11, 0.30, 0.135) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FootPad" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.20, 0.30, 0.16)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.159) + double3 xformOp:scale = (0.16, 0.36, 0.018) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cylinder "DrainPlug" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.022 + double radius = 0.016 + color3f[] primvars:displayColor = [(0.72, 0.53, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.700, 0, 0.115) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Xform "Suction" + { + def Cylinder "Flange" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.020 + double radius = 0.100 + color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Neck" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.055 + double radius = 0.051 + color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] + rel material:binding = + double3 xformOp:translate = (-0.822, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltTop" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0, 0.360) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltBottom" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0, 0.200) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, -0.080, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.034 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.860, 0.080, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Xform "Discharge" + { + def Cylinder "Neck" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.150 + double radius = 0.043 + color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.455) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Flange" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.018 + double radius = 0.0925 + color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltFront" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.5725, 0, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltBack" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.7175, 0, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.0725, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "BoltRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.032 + double radius = 0.009 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.645, -0.0725, 0.539) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Gauge" + { + def Cylinder "Stem" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.055 + double radius = 0.007 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.070, 0.470) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Dial" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.022 + double radius = 0.041 + color3f[] primvars:displayColor = [(0.90, 0.90, 0.88)] + rel material:binding = + double3 xformOp:translate = (-0.645, 0.108, 0.470) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + } + + def Xform "PowerEnd" + { + def Cylinder "SealHousing" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.060 + double radius = 0.046 + color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] + rel material:binding = + double3 xformOp:translate = (-0.560, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Lantern" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.040 + double radius = 0.058 + color3f[] primvars:displayColor = [(0.20, 0.30, 0.16)] + rel material:binding = + double3 xformOp:translate = (-0.510, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Bracket" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.220 + double radius = 0.0625 + color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] + rel material:binding = + double3 xformOp:translate = (-0.380, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "BracketFoot" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.20, 0.30, 0.16)] + rel material:binding = + double3 xformOp:translate = (-0.360, 0, 0.1875) + double3 xformOp:scale = (0.16, 0.20, 0.105) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cylinder "SightGlass" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.020 + double radius = 0.015 + color3f[] primvars:displayColor = [(0.85, 0.86, 0.62)] + rel material:binding = + double3 xformOp:translate = (-0.330, 0.064, 0.245) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def Cylinder "Bearing" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.036 + double radius = 0.052 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (-0.300, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Impeller" + { + double3 xformOp:translate = (0, 0, 0.28) + double xformOp:rotateY = 90 + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateY", "xformOp:rotateZ" + ] + + custom double inputs:speedSetpoint = 0 + + def Cylinder "Shaft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.760 + double radius = 0.019 + color3f[] primvars:displayColor = [(0.62, 0.63, 0.65)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.300) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Shroud" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.012 + double radius = 0.099 + color3f[] primvars:displayColor = [(0.66, 0.48, 0.22)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.664) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Hub" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.034 + double radius = 0.030 + color3f[] primvars:displayColor = [(0.66, 0.48, 0.22)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.645) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "VaneA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (0.052, 0.014, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 22) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (0.014, 0.052, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 82) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneC" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.038, 0.038, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 142) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneD" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.052, -0.014, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 202) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneE" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (-0.014, -0.052, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 262) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "VaneF" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] + rel material:binding = + double3 xformOp:translate = (0.038, -0.038, 0.646) + double3 xformOp:rotateXYZ = (0, 0, 322) + double3 xformOp:scale = (0.086, 0.010, 0.026) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cylinder "CouplingHubPump" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.052 + double radius = 0.070 + color3f[] primvars:displayColor = [(0.34, 0.35, 0.37)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.196) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "CouplingSpider" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.014 + double radius = 0.058 + color3f[] primvars:displayColor = [(0.92, 0.66, 0.06)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.163) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "CouplingHubMotor" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.052 + double radius = 0.070 + color3f[] primvars:displayColor = [(0.34, 0.35, 0.37)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.130) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "CouplingKey" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.94, 0.94, 0.95)] + rel material:binding = + double3 xformOp:translate = (0, 0, -0.168) + double3 xformOp:scale = (0.020, 0.140, 0.010) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Xform "CouplingGuard" + { + def Cylinder "RingPump" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.012 + double radius = 0.110 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.264, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "RingMotor" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.012 + double radius = 0.110 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.006, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "BarTop" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, 0, 0.388) + double3 xformOp:scale = (0.264, 0.030, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BarBottom" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, 0, 0.172) + double3 xformOp:scale = (0.264, 0.030, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BarLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, -0.108, 0.28) + double3 xformOp:scale = (0.264, 0.008, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BarRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] + rel material:binding = + double3 xformOp:translate = (-0.135, 0.108, 0.28) + double3 xformOp:scale = (0.264, 0.008, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Xform "Motor" + { + def Cylinder "Frame" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.615 + double radius = 0.127 + color3f[] primvars:displayColor = [(0.60, 0.61, 0.60)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "DriveEndShield" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.050 + double radius = 0.132 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.030, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "NonDriveEndShield" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.050 + double radius = 0.132 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.595, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "FanCowl" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.080 + double radius = 0.140 + color3f[] primvars:displayColor = [(0.48, 0.49, 0.50)] + rel material:binding = + double3 xformOp:translate = (0.660, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "FinTop" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0, 0.412) + double3 xformOp:scale = (0.615, 0.030, 0.024) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FinUpperLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, -0.098, 0.372) + double3 xformOp:rotateXYZ = (40, 0, 0) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "FinUpperRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0.098, 0.372) + double3 xformOp:rotateXYZ = (-40, 0, 0) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" + ] + } + + def Cube "FinLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, -0.132, 0.28) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FinRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] + rel material:binding = + double3 xformOp:translate = (0.3125, 0.132, 0.28) + double3 xformOp:scale = (0.615, 0.024, 0.030) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "TerminalBox" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.52, 0.53, 0.52)] + rel material:binding = + double3 xformOp:translate = (0.230, 0, 0.452) + double3 xformOp:scale = (0.120, 0.120, 0.080) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cylinder "CableGland" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.040 + double radius = 0.016 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0.230, -0.078, 0.440) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "EyeBolt" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.046 + double radius = 0.012 + color3f[] primvars:displayColor = [(0.62, 0.63, 0.65)] + rel material:binding = + double3 xformOp:translate = (0.470, 0, 0.428) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cube "Nameplate" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.86, 0.87, 0.88)] + rel material:binding = + double3 xformOp:translate = (0.150, -0.129, 0.318) + double3 xformOp:scale = (0.090, 0.004, 0.055) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FootLeft" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.310, -0.105, 0.179) + double3 xformOp:scale = (0.300, 0.044, 0.052) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "FootRight" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.310, 0.105, 0.179) + double3 xformOp:scale = (0.300, 0.044, 0.052) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } +} diff --git a/samples/PumpDeviceIntegrationServer/EventIds.cs b/samples/PumpDeviceIntegrationServer/EventIds.cs index fe39a29e99..aad4dd9e08 100644 --- a/samples/PumpDeviceIntegrationServer/EventIds.cs +++ b/samples/PumpDeviceIntegrationServer/EventIds.cs @@ -41,5 +41,7 @@ namespace Pumps internal static class PumpDeviceIntegrationServerEventIds { public const int PumpNodeManager = 0; + public const int OpenUsdComposition = 10; + public const int OpenUsdRepresentation = 20; } } diff --git a/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs b/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs new file mode 100644 index 0000000000..02564eb07b --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs @@ -0,0 +1,292 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.OpenUsd; +using Opc.Ua.OpenUsd.Server; +using Opc.Ua.Pumps; +using Opc.Ua.Server; +using Opc.Ua.Server.NodeManager; + +namespace Pumps +{ + /// + /// Wires the draft OPC UA โ€” OpenUSD Bindings composition/aggregation model + /// (spec ยง5.12โ€“5.14) onto the server: the pump composed 1:1 of Impeller and + /// Bearing component Objects (child prims), and a ProductionLine aggregating + /// 1..n pumps (Many, instanceable) with a dynamically added/removed pump + /// (model-change events) and a cross-server component (federation). + /// + public partial class PumpNodeManager + { + private BaseObjectState? m_productionLine; + private FolderState? m_linePumps; + private NodeId m_dynamicPumpNodeId; + private const string LinePrimPath = "/Plant/Line1"; + + // 1:1 (Child): create Impeller + Bearing component Objects on the pump, each + // with its own representation, and declare One bindings. + private void AttachPumpComponents(PumpState pump, OpenUsdRepresentationState pumpRep, ushort ns) + { + (BaseObjectState _, OpenUsdRepresentationState impellerRep) = CreateRepresentedComponent( + pump, "Impeller", pump.BrowseName.NamespaceIndex, "/Plant/Pumps/P101/Impeller", ns); + (BaseObjectState _, OpenUsdRepresentationState bearingRep) = CreateRepresentedComponent( + pump, "Bearing", pump.BrowseName.NamespaceIndex, "/Plant/Pumps/P101/Bearing", ns); + + CreateComponentBinding(pumpRep, ns, "ImpellerComponent", + new Guid("a1b2c3d4-0001-4000-8000-000000000001"), + OpenUsdCardinalityEnum.One, OpenUsdCompositionArcEnum.Child, + "/Plant/Pumps/P101/Impeller", componentRepresentation: impellerRep.NodeId); + CreateComponentBinding(pumpRep, ns, "BearingComponent", + new Guid("a1b2c3d4-0001-4000-8000-000000000002"), + OpenUsdCardinalityEnum.One, OpenUsdCompositionArcEnum.Child, + "/Plant/Pumps/P101/Bearing", componentRepresentation: bearingRep.NodeId); + } + + // 1..n + dynamic + cross-server: a ProductionLine aggregating pumps. + private async ValueTask MaterialiseProductionLineAsync(CancellationToken cancellationToken) + { + if (m_plantStage == null) + { + return; + } + try + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); + NodeState? deviceSet = PredefinedNodes.FindById(NodeId.Create( + Opc.Ua.Di.Objects.DeviceSet, DiNamespaceUri, Server.NamespaceUris)); + if (deviceSet == null) + { + return; + } + + var line = new BaseObjectState(deviceSet) + { + SymbolicName = "ProductionLine", + BrowseName = new QualifiedName("ProductionLine", ns), + DisplayName = new LocalizedText("ProductionLine"), + ReferenceTypeId = ReferenceTypeIds.HasComponent, + TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType + }; + deviceSet.AddChild(line); + line.NodeId = SystemContext.NodeIdFactory.New(SystemContext, line); + + OpenUsdRepresentationState lineRep = SystemContext.CreateInstanceOfOpenUsdRepresentationType( + line, new QualifiedName("OpenUsdRepresentation", ns)); + lineRep.ReferenceTypeId = ReferenceTypeIds.HasComponent; + line.AddChild(lineRep); + lineRep.NodeId = SystemContext.NodeIdFactory.New(SystemContext, lineRep); + lineRep.CreateOrReplaceStage(SystemContext, null!).Value = m_plantStage.NodeId; + lineRep.CreateOrReplacePrimPath(SystemContext, null!).Value = LinePrimPath; + + var pumps = new FolderState(line) + { + SymbolicName = "Pumps", + BrowseName = new QualifiedName("Pumps", ns), + DisplayName = new LocalizedText("Pumps"), + ReferenceTypeId = ReferenceTypeIds.Organizes, + TypeDefinitionId = Opc.Ua.ObjectTypeIds.FolderType + }; + line.AddChild(pumps); + pumps.NodeId = SystemContext.NodeIdFactory.New(SystemContext, pumps); + m_linePumps = pumps; + + // Two static aggregated pumps (1..n baseline). + CreateRepresentedComponent(pumps, "P-201", ns, + LinePrimPath + "/Pumps/P_201", ns, ReferenceTypeIds.Organizes); + CreateRepresentedComponent(pumps, "P-202", ns, + LinePrimPath + "/Pumps/P_202", ns, ReferenceTypeIds.Organizes); + + // Many : aggregate the Pumps as instanceable references, dynamic. + CreateComponentBinding(lineRep, ns, "PumpsAggregation", + new Guid("a1b2c3d4-0002-4000-8000-000000000001"), + OpenUsdCardinalityEnum.Many, OpenUsdCompositionArcEnum.Instance, + LinePrimPath + "/Pumps", + assetReference: "@pump.usda@", dynamic: true, + changeEventSource: Opc.Ua.ObjectIds.Server); + + // Cross-server : an OEM pump on another server (federation). + CreateComponentBinding(lineRep, ns, "RemotePumpComponent", + new Guid("a1b2c3d4-0003-4000-8000-000000000001"), + OpenUsdCardinalityEnum.One, OpenUsdCompositionArcEnum.Reference, + LinePrimPath + "/RemotePump", + assetReference: "@remote-pump.usda@", + componentServerUri: RemoteServerUri, componentEndpointUrl: RemoteEndpointUrl); + + SystemContext.AssignInstanceChildNodeIds(line); + await AddPredefinedNodeAsync(SystemContext, line, cancellationToken).ConfigureAwait(false); + + // Register the line representation in the discovery registry. + FolderState? registry = m_openUsdRoot?.Representations; + if (registry != null) + { + registry.AddReference(ReferenceTypeIds.Organizes, false, lineRep.NodeId); + lineRep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId); + } + + // Dynamic composition: emit model-change events on runtime add/remove. + ModelChangeEmissionEnabled = true; + _ = RunDynamicCompositionAsync(ns); + + m_productionLine = line; + m_logger.LogInformation("Materialised ProductionLine (aggregates 1..n pumps)."); + } + catch (Exception ex) + { + m_logger.LogError(ex, "Failed to materialise the ProductionLine."); + } + } + + // The cross-server component points at this same server process (a loopback + // stand-in for an OEM sub-asset server); a test overrides RemoteEndpointUrl. + private static string RemoteServerUri => "urn:localhost:OPCFoundation:PumpDeviceIntegrationServer"; + private string RemoteEndpointUrl { get; set; } = + "opc.tcp://localhost:62810/PumpDeviceIntegrationServer"; + + // Dynamic demo (ยง5.13): repeatedly add a pump (emits a GeneralModelChange), + // hold, then remove it (emits again), so a connector observes both the add and + // the remove regardless of when it connects. Bounded so the server quiesces. + private async Task RunDynamicCompositionAsync(ushort ns) + { + try + { + for (int i = 0; i < 3; i++) + { + await Task.Delay(4000).ConfigureAwait(false); + m_dynamicPumpNodeId = await AddLinePumpAsync("P-203", + LinePrimPath + "/Pumps/P_203", ns).ConfigureAwait(false); + await Task.Delay(4000).ConfigureAwait(false); + if (!m_dynamicPumpNodeId.IsNull) + { + await DeleteNodeAsync(SystemContext, m_dynamicPumpNodeId, CancellationToken.None) + .ConfigureAwait(false); + m_logger.RemovedLinePump(m_dynamicPumpNodeId); + m_dynamicPumpNodeId = NodeId.Null; + } + } + // Final add: leave P-203 in place so the composed stage renders it. + await Task.Delay(2000).ConfigureAwait(false); + m_dynamicPumpNodeId = await AddLinePumpAsync("P-203", + LinePrimPath + "/Pumps/P_203", ns).ConfigureAwait(false); + m_logger.LogInformation("Dynamic composition: P-203 left added (final state)."); + } + catch (Exception ex) + { + m_logger.LogWarning(ex, "Dynamic composition demo failed."); + } + } + + private async Task AddLinePumpAsync(string name, string primPath, ushort ns) + { + if (m_linePumps == null) + { + return NodeId.Null; + } + var pump = new BaseObjectState(null) + { + SymbolicName = name, + BrowseName = new QualifiedName(name, ns), + DisplayName = new LocalizedText(name), + TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType + }; + OpenUsdRepresentationState rep = SystemContext.CreateInstanceOfOpenUsdRepresentationType( + pump, new QualifiedName("OpenUsdRepresentation", ns)); + rep.ReferenceTypeId = ReferenceTypeIds.HasComponent; + pump.AddChild(rep); + rep.CreateOrReplaceStage(SystemContext, null!).Value = m_plantStage!.NodeId; + rep.CreateOrReplacePrimPath(SystemContext, null!).Value = primPath; + + NodeId newId = await CreateNodeAsync(SystemContext, m_linePumps.NodeId, + ReferenceTypeIds.Organizes, new QualifiedName(name, ns), pump, CancellationToken.None) + .ConfigureAwait(false); + m_logger.AddedLinePump(name, newId); + return newId; + } + + private (BaseObjectState, OpenUsdRepresentationState) CreateRepresentedComponent( + NodeState parent, string name, ushort objNs, string primPath, ushort openUsdNs, + NodeId refType = default) + { + var obj = new BaseObjectState(parent) + { + SymbolicName = name, + BrowseName = new QualifiedName(name, objNs), + DisplayName = new LocalizedText(name), + ReferenceTypeId = refType.IsNull ? ReferenceTypeIds.HasComponent : refType, + TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType + }; + parent.AddChild(obj); + obj.NodeId = SystemContext.NodeIdFactory.New(SystemContext, obj); + + OpenUsdRepresentationState rep = SystemContext.CreateInstanceOfOpenUsdRepresentationType( + obj, new QualifiedName("OpenUsdRepresentation", openUsdNs)); + rep.ReferenceTypeId = ReferenceTypeIds.HasComponent; + obj.AddChild(rep); + rep.NodeId = SystemContext.NodeIdFactory.New(SystemContext, rep); + rep.CreateOrReplaceStage(SystemContext, null!).Value = m_plantStage!.NodeId; + rep.CreateOrReplacePrimPath(SystemContext, null!).Value = primPath; + SystemContext.AssignInstanceChildNodeIds(obj); + return (obj, rep); + } + + // Thin adapter over the reusable Opc.Ua.OpenUsd.Server authoring API: the + // component/composition-binding logic lives in the SDK + // (OpenUsdRepresentationAuthoring.AddComponentBinding), not in this sample. + private void CreateComponentBinding( + OpenUsdRepresentationState rep, ushort ns, string name, Guid bindingDefinitionId, + OpenUsdCardinalityEnum cardinality, OpenUsdCompositionArcEnum arc, string targetPrimPath, + NodeId componentRepresentation = default, string? assetReference = null, + bool dynamic = false, NodeId changeEventSource = default, + string? componentServerUri = null, string? componentEndpointUrl = null, + NodeId componentTypeDefinition = default) + { + _ = rep.AddComponentBinding( + SystemContext, ns, name, bindingDefinitionId, cardinality, arc, targetPrimPath, + componentRepresentation, assetReference, dynamic, changeEventSource, + componentServerUri, componentEndpointUrl, componentTypeDefinition); + } + } + + internal static partial class OpenUsdCompositionLog + { + [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.OpenUsdComposition + 1, + Level = LogLevel.Information, + Message = "Dynamic composition: removed line pump (NodeId={NodeId}); model-change emitted.")] + public static partial void RemovedLinePump(this ILogger logger, NodeId nodeId); + + [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.OpenUsdComposition + 2, + Level = LogLevel.Information, + Message = "Dynamic composition: added line pump '{Name}' (NodeId={NodeId}); model-change emitted.")] + public static partial void AddedLinePump(this ILogger logger, string name, NodeId nodeId); + } +} diff --git a/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs b/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs new file mode 100644 index 0000000000..8ded3ce907 --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs @@ -0,0 +1,477 @@ +/* ======================================================================== + * 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.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.OpenUsd; +using Opc.Ua.OpenUsd.Server; +using Opc.Ua.Pumps; + +namespace Pumps +{ + /// + /// Wires the draft OPC UA โ€” OpenUSD Bindings companion model onto the Pumps + /// server: the well-known OpenUSD facility (stage + representation registries), + /// a PlantStage descriptor, and an OpenUsdRepresentation AddIn on Pump #1 with + /// three read-only live bindings (Part 2, UaToUsdTelemetry). + /// + public partial class PumpNodeManager + { + private OpenUsdRootState? m_openUsdRoot; + private OpenUsdStageState? m_plantStage; + private BaseDataVariableState? m_alarmActiveVar; + private BaseDataVariableState? m_speedSetpointVar; + private BaseDataVariableState? m_shaftAngleVar; + + /// + /// Rate at which the server samples the hand-built OpenUSD signals, in + /// milliseconds. Matches the simulation tick so every change is observed. + /// + private const double SignalSamplingIntervalMilliseconds = 250; + + /// + /// Registers the Variables that are hand-built onto the pump rather than + /// materialised by the generated factory. A generated instance state only + /// reports its declared members, so these children are never picked up by + /// the walk that AddPredefinedNodeAsync(pump) performs: they browse + /// and read correctly but are never sampled, which leaves any binding that + /// uses them frozen at its start-up value. + /// + private async ValueTask RegisterOpenUsdSignalsAsync(CancellationToken cancellationToken) + { + foreach (BaseDataVariableState? signal in new[] + { + m_alarmActiveVar, m_shaftAngleVar, m_speedSetpointVar + }) + { + if (signal != null) + { + await AddPredefinedNodeAsync(SystemContext, signal, cancellationToken) + .ConfigureAwait(false); + } + } + // Push the shaft angle from the same loop that drives the generated + // measurement Variables, so it uses the one path proven to raise + // data-change notifications. + if (m_shaftAngleVar != null) + { + TrackSignal(m_shaftAngleVar, () => ShaftAngleDegrees); + } + } + + private const string PlantRootLayerIdentifier = "asset-repo/Plant.usd"; + private const string PumpPrimPath = "/Plant/Pumps/P101"; + + private async ValueTask MaterialiseOpenUsdFacilityAsync( + CancellationToken cancellationToken) + { + try + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); + + OpenUsdRootState root = SystemContext.CreateInstanceOfOpenUsdRootType( + null!, new QualifiedName("OpenUSD", ns)); + root.NodeId = new NodeId("OpenUSD", ns); + + FolderState stages = root.Stages + ?? root.CreateOrReplaceStages(SystemContext, null!); + _ = root.Representations + ?? root.CreateOrReplaceRepresentations(SystemContext, null!); + + m_plantStage = SystemContext.CreateInstanceOfOpenUsdStageType( + stages, new QualifiedName("PlantStage", ns)); + stages.AddChild(m_plantStage); + m_plantStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!) + .Value = PlantRootLayerIdentifier; + + // ยง5.2 Twin-BOM content integrity: the digest is computed over the + // *resolved root-layer content*, never over the identifier string, so a + // connector can detect tampering of the bytes it actually composes. + List servedAssets = LoadServedAssets(); + byte[] rootLayerBytes = servedAssets + .Find(a => a.Kind == OpenUsdAssetKindEnum.RootLayer)!.Bytes; + byte[] digest; +#pragma warning disable CA1850 // Prefer static HashData (net48/netstandard2.0 compatibility) + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + digest = sha.ComputeHash(rootLayerBytes); + } +#pragma warning restore CA1850 + m_plantStage.CreateOrReplaceRootLayerDigest( + SystemContext, + SystemContext.CreateOpenUsdStageType_RootLayerDigest(m_plantStage, forInstance: true)) + .Value = (ByteString)digest; + m_plantStage.CreateOrReplaceRootLayerDigestAlgorithm( + SystemContext, + SystemContext.CreateOpenUsdStageType_RootLayerDigestAlgorithm(m_plantStage, forInstance: true)) + .Value = OpenUsdDigestAlgorithmEnum.Sha256; + + // Per the companion spec ยง4.2 the OpenUSD facility SHALL be a + // component of the Server Object (i=2253) so that any conformant + // connector can Browse Server -> OpenUSD -> Representations. Record + // the inverse reference here; the matching forward reference from + // the Server Object (owned by the core node manager) is added via + // the externalReferences dictionary in LinkOpenUsdRootToServer. + root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server); + + // ยง5.15 asset content delivery (OU-AssetDelivery): serve this stage's + // artist-authored USD layer closure so a connector can render the twin + // with no external asset resolver. + UsdAssetDelivery.AttachStageAssets(SystemContext, m_plantStage, ns, servedAssets); + + SystemContext.AssignInstanceChildNodeIds(root); + await AddPredefinedNodeAsync(SystemContext, root, cancellationToken) + .ConfigureAwait(false); + + m_openUsdRoot = root; + m_logger.MaterialisedOpenUsdFacility(root.NodeId, m_plantStage.NodeId); + } + catch (Exception ex) + { + m_plantStage = null; + m_openUsdRoot = null; + m_logger.LogError(ex, "Failed to materialise the OpenUSD facility."); + } + } + + // Loads the embedded artist-authored USD layers this server serves (spec ยง5.15). + private static List LoadServedAssets() + { + return new List + { + new ServedAsset("Plant.usda", OpenUsdAssetKindEnum.RootLayer, ReadEmbeddedAsset("Plant.usda")), + new ServedAsset("pump.usda", OpenUsdAssetKindEnum.Reference, ReadEmbeddedAsset("pump.usda")), + new ServedAsset("remote-pump.usda", OpenUsdAssetKindEnum.Reference, ReadEmbeddedAsset("remote-pump.usda")), + }; + } + + private static byte[] ReadEmbeddedAsset(string resourceName) + { + using Stream? s = typeof(PumpNodeManager).Assembly.GetManifestResourceStream(resourceName); + if (s == null) + { + return Array.Empty(); + } + using var ms = new MemoryStream(); + s.CopyTo(ms); + return ms.ToArray(); + } + + /// + public override async ValueTask CreateAddressSpaceAsync( + IDictionary> externalReferences, + CancellationToken cancellationToken = default) + { + // base.CreateAddressSpaceAsync loads predefined nodes and runs + // OnAddressSpaceReadyAsync, which materialises the OpenUSD facility. + await base.CreateAddressSpaceAsync(externalReferences, cancellationToken) + .ConfigureAwait(false); + + // Now that the root exists, add the forward HasComponent reference from + // the Server Object (i=2253, owned by the core node manager) to it. + LinkOpenUsdRootToServer(externalReferences); + } + + // Adds the Server -> OpenUSD forward reference into the externalReferences + // dictionary the master node manager applies across managers, so the + // well-known facility is browsable from the Server Object (spec ยง4.2). + private void LinkOpenUsdRootToServer( + IDictionary> externalReferences) + { + if (m_openUsdRoot == null) + { + return; + } + if (!externalReferences.TryGetValue(Opc.Ua.ObjectIds.Server, out IList? references) + || references == null) + { + externalReferences[Opc.Ua.ObjectIds.Server] = references = new List(); + } + references.Add(new NodeStateReference( + ReferenceTypeIds.HasComponent, false, m_openUsdRoot.NodeId)); + m_logger.LogInformation( + "Linked OpenUSD facility under the Server Object (i=2253)."); + } + + // Call before AddPredefinedNodeAsync(pump), so the binding source + // NodeIds captured here are the per-instance ones the generated + // CreateOrReplace/AddXxx helpers assigned. + private void AttachOpenUsdRepresentation(PumpState pump) + { + if (m_plantStage == null) + { + return; + } + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); + + OpenUsdRepresentationState rep = SystemContext + .CreateInstanceOfOpenUsdRepresentationType( + pump, new QualifiedName("OpenUsdRepresentation", ns)); + // The instance factory leaves ReferenceTypeId = Null; set HasComponent + // so the AddIn is browsable from the represented object. + rep.ReferenceTypeId = ReferenceTypeIds.HasComponent; + pump.AddChild(rep); + rep.NodeId = SystemContext.NodeIdFactory.New(SystemContext, rep); + + rep.CreateOrReplaceStage(SystemContext, null!).Value = m_plantStage.NodeId; + rep.CreateOrReplacePrimPath(SystemContext, null!).Value = PumpPrimPath; + + MeasurementsState? m = pump.Operational?.Measurements; + NodeId bearingTemp = m?.BearingTemperature?.NodeId ?? NodeId.Null; + NodeId diffPressure = m?.DifferentialPressure?.NodeId ?? NodeId.Null; + + // 0.2 UaAlarmToUsd: a supervision alarm active-state drives the status + // light visibility. A dedicated Boolean variable exposes the alarm + // aspect the simulation toggles (see AdvanceSimulation). + m_alarmActiveVar = CreatePumpVariable( + pump, "AlarmActive", Opc.Ua.DataTypeIds.Boolean, new Variant(false), writable: false); + // Serve the live flag from the simulation. The Variable is registered + // in its own right by RegisterOpenUsdSignalsAsync, which is what lets + // a monitored item sample it. + m_alarmActiveVar.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds; + m_alarmActiveVar.OnSimpleReadValue = + (ISystemContext context, NodeState node, ref Variant value) => + { + value = new Variant(AlarmActive); + return ServiceResult.Good; + }; + + // Shaft angular position. This is what makes the twin *look* like it + // is running: MassFlow is a rate, so binding it straight to a rotation + // op only ever produces a fixed fraction of a degree. Integrating it + // into an angle gives the impeller and coupling a continuous spin. + m_shaftAngleVar = CreatePumpVariable( + pump, "ShaftAngle", Opc.Ua.DataTypeIds.Double, new Variant(0.0), writable: false); + m_shaftAngleVar.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds; + m_shaftAngleVar.OnSimpleReadValue = + (ISystemContext context, NodeState node, ref Variant value) => + { + value = new Variant(ShaftAngleDegrees); + return ServiceResult.Good; + }; + + // Shaft position -> impeller rotation. MassFlow is a *rate*, so binding + // it straight to a rotation op pins the shaft at a fraction of a degree; + // the simulation integrates it into an angle instead. The scale slows + // the render to a legible ~45 degrees per second - a real 2900 rpm shaft + // would alias into a stroboscopic blur at any practical sampling rate. + CreateBinding(rep, ns, "ShaftSpin", + new Guid("6e63cf2c-f2de-4f78-a8f8-f0ccdbb7647a"), + m_shaftAngleVar.NodeId, "/Plant/Pumps/P101/Impeller", "xformOp:rotateZ", "double", + OpenUsdRenderTargetKindEnum.Rotation, 0.0025, + sourceSemanticId: MassFlowSemanticId); + // BearingTemperature is published in Kelvin (OPC 40223), but the + // DisplayColor render target ramps blue -> red over 20..100 degrees + // Celsius, so the binding declares the -273.15 shift. + CreateBinding(rep, ns, "BearingTempColor", + new Guid("b1a1f6f0-5c2b-5a1e-9f3a-2b7c4d8e0011"), + bearingTemp, "/Plant/Pumps/P101/Body/Mat/Surface", "inputs:diffuseColor", "color3f", + OpenUsdRenderTargetKindEnum.DisplayColor, 1.0, + offset: -273.15); + + // DifferentialPressure is published in Pascal; the EmissiveColor + // render target brightens over 0..6 bar, so scale Pa -> bar. + CreateBinding(rep, ns, "DiffPressureEmissive", + new Guid("c2b2a7e1-6d3c-5b2f-a04b-3c8d5e9f1122"), + diffPressure, "/Plant/Pumps/P101/StatusLight/Mat/Surface", "inputs:emissiveColor", "color3f", + OpenUsdRenderTargetKindEnum.EmissiveColor, 0.00001); + + CreateBinding(rep, ns, "AlarmActiveVisibility", + new Guid("d3c3b8f2-7e4d-5c30-b15c-4d9e6a0b2233"), + m_alarmActiveVar.NodeId, "/Plant/Pumps/P101/StatusLight", "visibility", "token", + OpenUsdRenderTargetKindEnum.Visibility, 1.0, + bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType, + alarmAspect: OpenUsdAlarmAspectEnum.ActiveState); + + // 0.2 UsdToUaCommand (opt-in): a writable speed setpoint Variable is the + // command target. The binding is Controllable and present, but a + // connector only issues the write when explicitly enabled AND authorized + // (single-writer, fail-closed). Enabled=true means "declared", NOT + // "auto-actuated" โ€” the opt-in lives on the connector, not on Enabled. + // ยง5.10/ยง9 authorization posture: the command target is *capable* of being + // written (AccessLevel), but the write right is withheld by default and + // granted only to an authenticated (non-anonymous) session โ€” a Server + // "withholds by default" the RolePermissions a connector must hold before + // issuing any command. + m_speedSetpointVar = CreatePumpVariable( + pump, "SpeedSetpoint", Opc.Ua.DataTypeIds.Double, new Variant(0.0), writable: true); + m_speedSetpointVar.OnReadUserAccessLevel = OnReadCommandTargetUserAccessLevel; + CreateBinding(rep, ns, "SpeedSetpointCommand", + new Guid("e4d4c9a3-8f5e-5d41-c26d-5e0f7b1c3344"), + NodeId.Null, "/Plant/Pumps/P101/Impeller", "inputs:speedSetpoint", "double", + kind: null, 1.0, + bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdCommandBindingType, + signalRole: OpenUsdSignalRoleEnum.Controllable, + commandTargetNodeId: m_speedSetpointVar.NodeId, + commandTriggerPropertyName: "inputs:speedSetpoint"); + + // Composition (ยง5.12): the pump is composed of an Impeller and a Bearing, + // each a component Object with its own representation, mapped 1:1 to a child + // prim (arc=Child). This adds bindings on the pump representation. + AttachPumpComponents(pump, rep, ns); + + SystemContext.AssignInstanceChildNodeIds(rep); + } + + // ECLASS-style IRDI for "volume flow rate" โ€” a portable semantic id a + // connector can use to resolve the source across vendors (0.2 SemanticSource). + private const string MassFlowSemanticId = "0173-1#02-AAO677#002"; + + /// + /// ยง5.10/ยง9: the write right a command target requires is withheld by + /// default โ€” an anonymous session sees a read-only UserAccessLevel and its + /// write is rejected. Only a session that authenticated (and therefore holds a + /// Role beyond Anonymous) sees CurrentWrite. + /// + private static ServiceResult OnReadCommandTargetUserAccessLevel( + ISystemContext context, NodeState node, ref byte value) + { + value = IsAuthenticatedSession(context) + ? AccessLevels.CurrentReadOrWrite + : AccessLevels.CurrentRead; + return ServiceResult.Good; + } + + private static bool IsAuthenticatedSession(ISystemContext context) + { + IUserIdentity? identity = (context as ISessionSystemContext)?.UserIdentity; + if (identity == null || identity.TokenType == UserTokenType.Anonymous) + { + return false; + } + ArrayOf roles = identity.GrantedRoleIds; + for (int i = 0; i < roles.Count; i++) + { + if (roles[i] != Opc.Ua.ObjectIds.WellKnownRole_Anonymous) + { + return true; + } + } + return false; + } + + // Creates a simple Variable child on the pump (used for the 0.2 command + // setpoint and alarm-active demo signals), assigning a per-instance NodeId + // immediately: the node is hand-built, so no generated helper assigns one. + private BaseDataVariableState CreatePumpVariable( + PumpState pump, string name, NodeId dataType, Variant initialValue, bool writable) + { + byte access = writable + ? AccessLevels.CurrentReadOrWrite + : AccessLevels.CurrentRead; + var v = new BaseDataVariableState(pump) + { + SymbolicName = name, + BrowseName = new QualifiedName(name, pump.BrowseName.NamespaceIndex), + DisplayName = new LocalizedText(name), + ReferenceTypeId = ReferenceTypeIds.HasComponent, + TypeDefinitionId = VariableTypeIds.BaseDataVariableType, + DataType = dataType, + ValueRank = ValueRanks.Scalar, + AccessLevel = access, + UserAccessLevel = access, + Value = initialValue, + }; + pump.AddChild(v); + v.NodeId = SystemContext.NodeIdFactory.New(SystemContext, v); + return v; + } + + private void OrganiseRepresentation(PumpState pump) + { + FolderState? registry = m_openUsdRoot?.Representations; + if (registry == null) + { + return; + } + foreach (BaseInstanceState child in EnumerateChildren(pump)) + { + if (child is OpenUsdRepresentationState rep) + { + registry.AddReference(ReferenceTypeIds.Organizes, false, rep.NodeId); + rep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId); + } + } + } + + private System.Collections.Generic.List EnumerateChildren(NodeState parent) + { + var children = new System.Collections.Generic.List(); + parent.GetChildren(SystemContext, children); + return children; + } + + // Thin adapter over the reusable Opc.Ua.OpenUsd.Server authoring API: binds the + // plant stage and forwards. The binding-authoring logic lives in the SDK + // (OpenUsdRepresentationAuthoring.AddLiveBinding), not in this sample. + private void CreateBinding( + OpenUsdRepresentationState rep, ushort ns, string name, + Guid bindingDefinitionId, NodeId sourceNodeId, string targetPrimPath, + string targetPropertyName, string targetUsdTypeName, + OpenUsdRenderTargetKindEnum? kind, double scale, + uint bindingTypeId = Opc.Ua.OpenUsd.ObjectTypes.OpenUsdValueChangeBindingType, + OpenUsdSignalRoleEnum signalRole = OpenUsdSignalRoleEnum.Observable, + string? sourceSemanticId = null, + OpenUsdAlarmAspectEnum? alarmAspect = null, + NodeId commandTargetNodeId = default, + string? commandTriggerPropertyName = null, + double offset = 0.0) + { + OpenUsdLiveBindingState binding = rep.AddLiveBinding( + SystemContext, ns, m_plantStage!.NodeId, name, bindingDefinitionId, sourceNodeId, + targetPrimPath, targetPropertyName, targetUsdTypeName, kind, scale, + bindingTypeId, signalRole, sourceSemanticId, alarmAspect, + commandTargetNodeId, commandTriggerPropertyName); + + // 5.8 applies Scale then Offset. AddLiveBinding only sets Scale, so an + // additive term (such as the Kelvin -> Celsius shift the DisplayColor + // render target expects) has to be authored here. + if (offset != 0.0) + { + binding.CreateOrReplaceOffset( + SystemContext, + SystemContext.CreateOpenUsdLiveBindingType_Offset(binding, forInstance: true)) + .Value = offset; + } + } + } + + internal static partial class OpenUsdRepresentationLog + { + [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.OpenUsdRepresentation + 1, + Level = LogLevel.Information, + Message = "Materialised OpenUSD facility (root {RootId}, PlantStage {StageId}).")] + public static partial void MaterialisedOpenUsdFacility(this ILogger logger, NodeId rootId, NodeId stageId); + } +} diff --git a/samples/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj b/samples/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj index a5885fe90a..e83ee45f02 100644 --- a/samples/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj +++ b/samples/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj @@ -17,6 +17,8 @@ + + @@ -28,6 +30,13 @@ + + + Plant.usda + pump.usda + remote-pump.usda + + + + http://opcfoundation.org/UA/OpenUSD/ + + + + + + + + i=1 + i=7 + i=11 + i=12 + i=14 + i=15 + i=17 + i=20 + i=21 + i=540 + i=887 + i=47 + i=46 + i=45 + i=35 + i=40 + i=37 + i=17603 + i=78 + i=80 + i=11508 + + + OpenUsdRenderTargetKindEnum + Classifies the USD render target a live value drives (advisory routing hint). + OpenUSD Binding DataTypes + + i=29 + ns=1;i=3902 + + A geodetic anchor coordinate (latitude/longitude/height) on a USD georeference or globe-anchor schema; see the geospatial rule (Bindings spec ยง5.8) and Annex D. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3002 + + TranslationRotationScaleTransformVisibilityDisplayColorEmissiveColorOpacityCustomGeoreference + + + OpenUsdBadQualityActionEnum + What the connector does with a non-Good source value. + OpenUSD Binding DataTypes + + i=29 + ns=1;i=3903 + + Do not update the target.Keep the last Good value.Remove the authored opinion (reveal a weaker layer).Author a configured fallback value. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3003 + + SkipHoldLastClearOpinionFallback + + + OpenUsdBindingStateEnum + Runtime lifecycle state of a live binding, exposed for diagnostics. + OpenUSD Binding DataTypes + + i=29 + ns=1;i=3904 + + + + + EnumStrings + + i=78 + i=68 + ns=1;i=3004 + + DisabledUnresolvedReadyActiveDegradedError + + + OpenUsdLiveBindingType + Abstract base for one read-only live binding: a source OPC UA Variable value drives one target USD attribute. The binding intent is expressed by the concrete subtype (ValueChange/Alarm/History/Command), not by an enum. The declaration is portable; the runtime connector resolves and applies it. The effective runtime identity is (represented object, BindingDefinitionId). + OpenUSD Binding + + i=58 + ns=1;i=6001 + ns=1;i=6002 + ns=1;i=6003 + ns=1;i=6004 + ns=1;i=6005 + ns=1;i=6006 + ns=1;i=6007 + ns=1;i=6008 + ns=1;i=6009 + ns=1;i=6010 + ns=1;i=6011 + ns=1;i=6012 + ns=1;i=6013 + ns=1;i=6014 + ns=1;i=6015 + ns=1;i=6016 + ns=1;i=6017 + ns=1;i=6018 + ns=1;i=6019 + ns=1;i=6031 + ns=1;i=6032 + ns=1;i=6067 + + + + BindingDefinitionId + Stable declaration identifier used for override/tombstone matching across type and instance levels. NOT a runtime instance key. + + i=78 + i=68 + ns=1;i=1004 + + + + Enabled + False acts as a tombstone that suppresses an inherited binding. + + i=78 + i=68 + ns=1;i=1004 + + + + SourceNodeId + Absolute NodeId of the source Variable (instance-level). Optional; prefer SourceBrowsePath for type-level declarations. + + i=80 + i=68 + ns=1;i=1004 + + + + SourceBrowsePath + RelativePath to the source Variable, resolved from the represented object. Preferred, instance-portable form. + + i=80 + i=68 + ns=1;i=1004 + + + + AttributeId + Source attribute id; default 13 (Value). Telemetry binds Value only. + + i=80 + i=68 + ns=1;i=1004 + + + + TargetStage + NodeId of the stage holding the target prim: an OpenUsdStageType instance, or a materialized Part 2 UsdStageType instance when the target is an in-server materialized scene. + + i=78 + i=68 + ns=1;i=1004 + + + + TargetPrimPath + Prim path of the target: absolute, or relative to the representation PrimPath. + + i=78 + i=68 + ns=1;i=1004 + + + + TargetPropertyName + USD attribute name on the target prim, e.g. 'xformOp:rotateZ' or 'primvars:displayColor'. + + i=78 + i=68 + ns=1;i=1004 + + + + TargetUsdTypeName + Expected USD Sdf value type name, e.g. 'double', 'float', 'bool', 'color3f'. + + i=78 + i=68 + ns=1;i=1004 + + + + RenderTargetKind + Advisory routing hint for the render target category. + + i=80 + i=68 + ns=1;i=1004 + + + + ValueSemanticUri + Value semantics: scalar/angle/length/point/vector/quaternion/matrix. The transform profile uses RSL CartesianFrameAngleOrientationType semantics. + + i=80 + i=68 + ns=1;i=1004 + + + + Scale + Linear conversion scale: target = Scale * converted + Offset. + + i=80 + i=68 + ns=1;i=1004 + + + + Offset + Linear conversion offset. + + i=80 + i=68 + ns=1;i=1004 + + + + SourceEngineeringUnits + Asserted source engineering units (UNECE). + + i=80 + i=68 + ns=1;i=1004 + + + + TargetEngineeringUnits + Requested target engineering units. + + i=80 + i=68 + ns=1;i=1004 + + + + BadQualityAction + How the connector treats a non-Good source value. Default Skip. + + i=80 + i=68 + ns=1;i=1004 + + + + SamplingIntervalHint + Requested sampling interval hint in milliseconds. A HINT only; the actual MonitoredItem parameters are negotiated per client Subscription. + + i=80 + i=68 + ns=1;i=1004 + + + + State + Runtime lifecycle state (diagnostic). + + i=80 + i=68 + ns=1;i=1004 + + + + LastError + Last operation error text (diagnostic). + + i=80 + i=68 + ns=1;i=1004 + + + + OpenUsdStageType + Describes one OpenUSD stage available to the server. Identity is the root layer identifier plus, where used, the session layer and resolver context; the stage Object NodeId is its same-server identity. + OpenUSD Binding + + i=58 + ns=1;i=6020 + ns=1;i=6021 + ns=1;i=6022 + ns=1;i=6023 + ns=1;i=6038 + ns=1;i=6039 + ns=1;i=6040 + ns=1;i=6041 + ns=1;i=6065 + + + + RootLayerIdentifier + Opaque authored root-layer / resolver identifier (NOT necessarily a URI). + + i=78 + i=68 + ns=1;i=1002 + + + + SessionLayerIdentifier + Opaque session-layer identifier, if any. Contributes to stage identity. + + i=80 + i=68 + ns=1;i=1002 + + + + ResolverContext + Opaque resolver-context descriptor (e.g. a named context profile). + + i=80 + i=68 + ns=1;i=1002 + + + + ResolvedRootLayerUri + Informative current resolution as a URI, when representable. Not authoritative. + + i=80 + i=68 + ns=1;i=1002 + + + + OpenUsdRepresentationType + AddIn that binds a domain Object to a canonical composed USD prim path on a specific stage. Mounted with HasAddIn; carries live bindings as children. + OpenUSD Binding + + i=58 + ns=1;i=6024 + ns=1;i=6025 + ns=1;i=6026 + ns=1;i=6027 + ns=1;i=6059 + + + + DefaultInstanceBrowseName + Default BrowseName for instances of this AddIn (Part 3 AddIn convention). + + i=68 + ns=1;i=1003 + + 1OpenUsdRepresentation + + + Stage + NodeId of the OpenUsdStageType instance this representation targets. + + i=78 + i=68 + ns=1;i=1003 + + + + PrimPath + Canonical, absolute, composed-stage prim path (SdfPath) for this instance. Instance-level: a reusable type cannot supply an absolute instance path. + + i=78 + i=68 + ns=1;i=1003 + + + + <Binding> + A live binding whose source resolves relative to the represented object. + + i=11508 + ns=1;i=1004 + ns=1;i=1003 + + + + OpenUsdRootType + Root of the server-wide OpenUSD facility. Contains the stage registry and the representation registry for deterministic discovery. + OpenUSD Binding + + i=58 + ns=1;i=6028 + ns=1;i=6029 + + + + Stages + Registry of OpenUsdStageType instances available to the server. + + i=78 + i=61 + ns=1;i=1001 + + + + Representations + Registry that Organizes every OpenUsdRepresentation AddIn in the server, so a connector can enumerate all representations from one place. + + i=78 + i=61 + ns=1;i=1001 + + + + IOpenUsdRepresentedType + Optional interface advertising that a domain ObjectType participates in OpenUSD representation. Applied with HasInterface; informative for browsing. + OpenUSD Binding + + i=17602 + ns=1;i=6030 + + + + <OpenUsdRepresentation> + Placeholder for the representation AddIn on an implementing instance. + + i=11508 + ns=1;i=1003 + ns=1;i=1010 + + + + OpenUsdSignalRoleEnum + Role of the bound signal, mirroring the asset-definition observable/controllable tagging. Only Controllable signals are eligible for a command binding. + OpenUSD Binding DataTypes + + i=29 + ns=1;i=3905 + + Read-only; drives USD but cannot be commanded.May be commanded via an opt-in, authorized command binding. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3005 + + ObservableControllable + + + OpenUsdAlarmAspectEnum + For OpenUsdAlarmBindingType bindings: which A&C condition aspect drives the target attribute. + OpenUSD Binding DataTypes + + i=29 + ns=1;i=3906 + + Condition ActiveState (boolean).Condition Severity (numeric).Condition AckedState (boolean).Condition EnabledState (boolean). + + + EnumStrings + + i=78 + i=68 + ns=1;i=3006 + + ActiveStateSeverityAckedStateEnabledState + + + OpenUsdDigestAlgorithmEnum + Digest algorithm for OpenUsdStageType.RootLayerDigest (Twin BOM content integrity). + OpenUSD Binding DataTypes + + i=29 + ns=1;i=3907 + + SHA-256.SHA-384.SHA-512. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3007 + + NoneSha256Sha384Sha512 + + + SignalRole + Observable (read-only, default) or Controllable (eligible for a command binding). + + i=80 + i=68 + ns=1;i=1004 + + + + SourceSemanticId + Semantic identifier (e.g. ECLASS / IEC CDD IRDI) of the source signal; resolved against the source's semantic annotations for cross-vendor portability. + + i=80 + i=68 + ns=1;i=1004 + + + + OpenUsdValueChangeBindingType + A source UA Variable Value change drives a USD attribute (the default binding). Adds no members beyond the abstract base; binds the source Value (AttributeId 13). + OpenUSD Binding + + ns=1;i=1004 + + + + OpenUsdAlarmBindingType + Read-only OPC UA A&C condition aspect (Part 9) drives a USD attribute. + OpenUSD Binding + + ns=1;i=1004 + ns=1;i=6033 + + + + AlarmAspect + Which A&C condition aspect drives the target (ActiveState/Severity/AckedState/EnabledState). + + i=80 + i=68 + ns=1;i=1008 + + + + OpenUsdHistoryBindingType + Read-only history (Part 11 HistoryRead) authored as USD time samples. + OpenUSD Binding + + ns=1;i=1004 + ns=1;i=6034 + + + + TimeSampled + Author values as USD time samples (playback) rather than the latest default. + + i=80 + i=68 + ns=1;i=1009 + + + + OpenUsdCommandBindingType + Opt-in, authorized USD-side intent drives an OPC UA write / Method call (USD -> UA). + OpenUSD Binding + + ns=1;i=1004 + ns=1;i=6035 + ns=1;i=6036 + ns=1;i=6037 + + + + CommandTargetNodeId + The Variable to write, or the Object on which to Call CommandMethodId. + + i=78 + i=68 + ns=1;i=1011 + + + + CommandMethodId + Optional Method to invoke instead of a Variable write. + + i=80 + i=68 + ns=1;i=1011 + + + + CommandTriggerPropertyName + The USD attribute whose change is interpreted as the command intent/value. + + i=78 + i=68 + ns=1;i=1011 + + + + RootLayerDigest + Cryptographic digest of the resolved root layer, for content-integrity verification (Twin BOM). Verify before composing the stage. + + i=80 + i=68 + ns=1;i=1002 + + + + RootLayerDigestAlgorithm + Digest algorithm for RootLayerDigest (default SHA-256). + + i=80 + i=68 + ns=1;i=1002 + + + + Signature + Optional detached signature over the digest / stage identity, for provenance. + + i=80 + i=68 + ns=1;i=1002 + + + + ProvenanceUri + Optional URI locating provenance / a signed Twin BOM manifest for the stage. + + i=80 + i=68 + ns=1;i=1002 + + + + OpenUsdCardinalityEnum + Cardinality of a component binding: 1:1 or 1..n. + OpenUSD Binding DataTypes + + i=29 + ns=1;i=3908 + + Exactly one component (1:1).Zero or more components of a type (1..n). + + + EnumStrings + + i=78 + i=68 + ns=1;i=3008 + + OneMany + + + OpenUsdCompositionArcEnum + USD composition arc used to place a component prim under its parent. + OpenUSD Binding DataTypes + + i=29 + ns=1;i=3909 + + Inline nested prim under the parent prim.Prim that references the component's external asset.Prim that payloads (deferred-load) the component's external asset.Instanceable reference (instanceable=true), for efficient 1..n. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3009 + + ChildReferencePayloadInstance + + + OpenUsdComponentBindingType + One composition/aggregation binding: maps an OPC UA component relationship of the represented Object onto a USD composition arc, so a connector assembles the component prim(s). Carried as a <Component> child of a representation. + OpenUSD Binding + + i=58 + ns=1;i=6042 + ns=1;i=6043 + ns=1;i=6044 + ns=1;i=6045 + ns=1;i=6046 + ns=1;i=6047 + ns=1;i=6048 + ns=1;i=6049 + ns=1;i=6050 + ns=1;i=6051 + ns=1;i=6052 + ns=1;i=6053 + ns=1;i=6054 + ns=1;i=6055 + ns=1;i=6056 + ns=1;i=6057 + ns=1;i=6058 + ns=1;i=6066 + + + + BindingDefinitionId + Stable declaration id for override/tombstone matching. + + i=78 + i=68 + ns=1;i=1005 + + + + Enabled + False acts as a tombstone that suppresses an inherited component binding. + + i=78 + i=68 + ns=1;i=1005 + + + + Cardinality + One (1:1) or Many (1..n). + + i=78 + i=68 + ns=1;i=1005 + + + + CompositionArc + How the component maps into the parent prim: Child, Reference, Payload, Instance. + + i=78 + i=68 + ns=1;i=1005 + + + + ComponentReferenceType + Aggregating ReferenceType from the represented Object to its component(s); default HasComponent. + + i=80 + i=68 + ns=1;i=1005 + + + + ComponentBrowsePath + RelativePath to the component Object (One) or to the container from which components are enumerated (Many). + + i=80 + i=68 + ns=1;i=1005 + + + + ComponentTypeDefinition + Expected component ObjectType; selects children for Many and locates each component's own representation. + + i=80 + i=68 + ns=1;i=1005 + + + + TargetPrimPath + Child prim (One) or parent scope prim (Many), relative to the parent representation PrimPath (or absolute). + + i=78 + i=68 + ns=1;i=1005 + + + + TargetPrimNameSource + For Many: how to name each instance prim (BrowseName default, a source-property RelativePath, or a {...} template). + + i=80 + i=68 + ns=1;i=1005 + + + + ComponentAssetReference + For Reference/Payload/Instance: the external USD asset + default prim, e.g. @pump.usda@</Pump>. + + i=80 + i=68 + ns=1;i=1005 + + + + ComponentRepresentation + NodeId of the component's own OpenUsdRepresentation AddIn (its sub-bindings compose under the component prim). + + i=80 + i=68 + ns=1;i=1005 + + + + Dynamic + The component set may change at runtime (reconciled from model-change events). + + i=80 + i=68 + ns=1;i=1005 + + + + ChangeEventSource + Node whose GeneralModelChange/SemanticChange events signal recomposition; default the Server Object (i=2253). + + i=80 + i=68 + ns=1;i=1005 + + + + ComponentServerUri + For a component on another server: the remote Server's application/namespace URI. + + i=80 + i=68 + ns=1;i=1005 + + + + ComponentEndpointUrl + For a component on another server: the remote endpoint URL (else discovered). + + i=80 + i=68 + ns=1;i=1005 + + + + State + Runtime lifecycle state (diagnostic). + + i=80 + i=68 + ns=1;i=1005 + + + + LastError + Last operation error text (diagnostic). + + i=80 + i=68 + ns=1;i=1005 + + + + <Component> + A component/aggregation binding composing this Object's components into the USD prim tree. + + i=11508 + ns=1;i=1005 + ns=1;i=1003 + + + + OpenUsdAssetKindEnum + Role of a served USD asset within a stage's served layer closure. + OpenUSD Binding DataTypes + + i=29 + ns=1;i=3910 + + The stage's root/base layer (exactly one served RootLayer per stage).A sublayer contributing to the root layer's composition.An asset introduced by a reference arc (e.g. a component's asset).An asset introduced by a payload arc (deferred-loaded).A texture / image asset referenced by a material.A packaged asset bundle (e.g. USDZ) carrying the whole closure. + + + EnumStrings + + i=78 + i=68 + ns=1;i=3010 + + RootLayerSubLayerReferencePayloadTexturePackage + + + OpenUsdAssetType + One served USD asset/layer: authored content the server delivers through the address space so a connector can fetch it and compose the stage locally, with no external resolver. OpenUsdAssetType subtypes the Part 5 FileType, so the asset's bytes are streamed directly through the node's own Open/Read/Close; AssetIdentifier is the resolver identifier / relative path used to place the asset in the local cache so that @...@ references resolve. + OpenUSD Binding + + i=11575 + ns=1;i=6060 + ns=1;i=6061 + ns=1;i=6062 + ns=1;i=6063 + ns=1;i=6064 + + + + AssetIdentifier + Resolver identifier / relative path of this asset, matching the stage RootLayerIdentifier or a ComponentAssetReference asset path; used for @...@ resolution and cache placement. + + i=78 + i=68 + ns=1;i=1006 + + + + AssetKind + Role of this asset within the stage's served layer closure. + + i=78 + i=68 + ns=1;i=1006 + + + + MediaType + IANA media type of the content, e.g. 'model/vnd.usda', 'model/vnd.usdz+zip', 'image/png'. + + i=80 + i=68 + ns=1;i=1006 + + + + Digest + Cryptographic digest of this asset's resolved content, for per-layer integrity verification. A connector verifies it before composing the asset. + + i=80 + i=68 + ns=1;i=1006 + + + + DigestAlgorithm + Digest algorithm for Digest (default SHA-256). + + i=80 + i=68 + ns=1;i=1006 + + + + Assets + Optional registry of OpenUsdAssetType instances forming this stage's served layer closure (exactly one RootLayer). Present only when the server delivers its geometry; a connector that finds it fetches and composes the stage locally, else it resolves RootLayerIdentifier externally as before. + + i=80 + i=61 + ns=1;i=1002 + + + + ComponentAssetNode + NodeId of the OpenUsdAssetType (under the stage's Assets folder) serving this component's asset, when the server delivers it. Complements ComponentAssetReference. + + i=80 + i=68 + ns=1;i=1005 + + + + TargetNodeId + Optional direct NodeId of the target when it is an in-server materialized Variable (a Part 2 UsdAttributeType). When present it takes precedence over the TargetPrimPath/TargetPropertyName pair, which remain the portable descriptors. + + i=80 + i=68 + ns=1;i=1004 + + + diff --git a/src/Opc.Ua.OpenUsd/NugetREADME.md b/src/Opc.Ua.OpenUsd/NugetREADME.md new file mode 100644 index 0000000000..e6959de7d7 --- /dev/null +++ b/src/Opc.Ua.OpenUsd/NugetREADME.md @@ -0,0 +1,18 @@ +# Opc.Ua.OpenUsd + +Draft **OPC UA โ€“ OpenUSD Bindings** companion information model for the OPC UA .NET Standard stack. + +It carries the shared `Opc.Ua.OpenUsdBinding` NodeSet and the source-generated model +(type NodeIds, `NodeState` classes and enumerations) in the `Opc.Ua.OpenUsd` namespace: + +- `OpenUsdRootType` / `OpenUsdStageType` / `OpenUsdRepresentationType` +- the abstract `OpenUsdLiveBindingType` and its intent subtypes + (`OpenUsdValueChangeBindingType`, `OpenUsdAlarmBindingType`, `OpenUsdHistoryBindingType`, + `OpenUsdCommandBindingType`) +- `OpenUsdComponentBindingType` (composition/aggregation) and `OpenUsdAssetType : FileType` + (Part 5 asset content delivery) + +Server-side reusable functionality lives in **Opc.Ua.OpenUsd.Server**; the generic, +dependency-injectable connector lives in **Opc.Ua.OpenUsd.Client**. + +This is a draft companion model published for review; NodeIds and shapes may change. diff --git a/src/Opc.Ua.OpenUsd/Opc.Ua.OpenUsd.csproj b/src/Opc.Ua.OpenUsd/Opc.Ua.OpenUsd.csproj new file mode 100644 index 0000000000..cac217efc6 --- /dev/null +++ b/src/Opc.Ua.OpenUsd/Opc.Ua.OpenUsd.csproj @@ -0,0 +1,55 @@ + + + $(AssemblyPrefix).OpenUsd + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.OpenUsd + Opc.Ua.OpenUsd + $(NoWarn);CS1591 + enable + Draft OPC UA - OpenUSD Bindings companion information model (representation, live/alarm/history/command bindings, composition, content integrity and asset content delivery), source-generated from the shared NodeSet2.xml. + true + NugetREADME.md + MIT + true + true + + + + + + + + + + + + $(PackageId).Debug + + + + + + + Analyzer + false + + + + + + + + + Opc.Ua.OpenUsd + + + + + + v105 + true + + + + + diff --git a/src/Opc.Ua.OpenUsd/Properties/AssemblyInfo.cs b/src/Opc.Ua.OpenUsd/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.OpenUsd/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.OpenUsdScene.Server/NugetREADME.md b/src/Opc.Ua.OpenUsdScene.Server/NugetREADME.md new file mode 100644 index 0000000000..a918d0acff --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene.Server/NugetREADME.md @@ -0,0 +1,49 @@ +# Opc.Ua.OpenUsdScene.Server + +Server-side functionality for the draft **OPC UA โ€” OpenUSD Scene Materialization (Part 2)** +specification: it materializes a composed USD stage into an OPC UA address space, and reads it +back out again. + +Where the Bindings model (Part 1) keeps the USD scene *outside* OPC UA and binds process values +to it, Scene Materialization puts the scene *inside* the address space โ€” the prim hierarchy +becomes the node hierarchy, so browsing the server is browsing the scene. + +## What it does + +| API | Purpose | +|---|---| +| `ISystemContext.MaterializeUsdStage(...)` | Materializes a `UsdStage` as a `UsdStageType` Object with the full prim tree (ยง7.1) | +| `ISystemContext.ExportUsdStage(...)` | Reads a materialized stage back into a scene document (ยง7.2) | +| `ISystemContext.EnsureStagesFolder(...)` | Finds or creates the discovery folder stages live under (ยง4.3) | +| `UsdMaterializationResult.TryResolveBindingTarget(...)` | Resolves the attribute a Part 1 binding targets (ยง10) | + +## Conformance units + +`UsdMaterializationOptions` switches map one-to-one onto the specification's conformance units +(ยง12), so a Server materializes only what it needs. **Scene Structure** is the always-on +baseline; `MaterializeComposition`, `MaterializeAppliedSchemas`, `MaterializeMetadata`, +`DualAuthorPortableGeoreference` and `HistorizeLiveAttributes` select the rest. + +## Notable behaviour + +- **Typed prims.** A prim whose `typeName` is a known UsdGeom or UsdShade schema is + materialized as the matching ObjectType subtype (ยง5.3). An **unknown** typed schema is never + dropped โ€” it stays a `UsdPrimType` carrying its `TypeName` token, so an exporter reproduces + it faithfully (ยง8.4). The same applies to unknown API schemas and unknown value types. +- **Reversible typing.** Every attribute records the exact `SdfValueTypeName` in `UsdTypeName`, + which is what makes the many-to-one ยง6.2 DataType mapping reversible. +- **Georeferencing.** When a Cesium georeference or globe-anchor schema is recognised, the + portable `UsdGeoreferenceApiType` / `UsdGlobeAnchorApiType` is dual-authored with the same + values, so a generic client reads the anchor from one well-known type (ยง5.8, Annex B). A + partial anchor is not published โ€” a wrong position is worse than none. +- **Live attributes.** An attribute flagged live is materialized for Mode A (ยง9): the Value is + server-maintained, and where retained it is exposed through HistoricalAccess. Driving those + values is a Server-side responsibility โ€” an external Part 1 connector authors into a USD sink + and cannot write an in-server Variable. + +## Related packages + +- `Opc.Ua.OpenUsdScene` โ€” the companion information model plus the `.usda` reader/writer +- `Opc.Ua.OpenUsd` / `Opc.Ua.OpenUsd.Server` โ€” the Part 1 Bindings model + +> This package tracks a **draft** specification; APIs, model URI and NodeIds may change. diff --git a/src/Opc.Ua.OpenUsdScene.Server/Opc.Ua.OpenUsdScene.Server.csproj b/src/Opc.Ua.OpenUsdScene.Server/Opc.Ua.OpenUsdScene.Server.csproj new file mode 100644 index 0000000000..625ecdfb81 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene.Server/Opc.Ua.OpenUsdScene.Server.csproj @@ -0,0 +1,28 @@ + + + $(AssemblyPrefix).OpenUsdScene.Server + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.OpenUsdScene.Server + Opc.Ua.OpenUsdScene.Server + $(NoWarn);CS1591 + enable + Server-side reusable functionality for the draft OPC UA - OpenUSD Scene Materialization companion model: materializes a composed USD stage into the address space as a browsable prim tree with typed prims, attributes, relationships, composition provenance, applied API schemas and georeferencing, and exports it back to .usda. + true + NugetREADME.md + MIT + true + true + + + + + + $(PackageId).Debug + + + + + + + + diff --git a/src/Opc.Ua.OpenUsdScene.Server/Properties/AssemblyInfo.cs b/src/Opc.Ua.OpenUsdScene.Server/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene.Server/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneDiscovery.cs b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneDiscovery.cs new file mode 100644 index 0000000000..a216726109 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneDiscovery.cs @@ -0,0 +1,410 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.OpenUsdScene.Server +{ + /// + /// Discovery and Part 1 interoperability helpers for materialized scenes + /// (draft OPC UA โ€” OpenUSD Scene Materialization ยง4.3, ยง10). + /// + /// + /// A Server should expose its materialized stages under one well-known folder so a client + /// starts at a single entry point and browses HasComponent into the prim tree: + /// Part 1's Server/OpenUSD/Stages when the Bindings model is also implemented, or + /// a standalone Server/OpenUSDScene/Stages otherwise. + /// + public static class UsdSceneDiscovery + { + /// + /// The standalone discovery root BrowseName. + /// + public const string OpenUsdSceneRootName = "OpenUSDScene"; + + /// + /// The BrowseName of the stages folder under either discovery root. + /// + public const string StagesFolderName = "Stages"; + + /// + /// The BrowseName of Part 1's TargetNodeId live-binding member (Part 1 ยง; the + /// NodeId form of a binding target). Named by string so Part 2 authors it without a + /// compile-time dependency on the Part 1 model (ยง4.2 โ€” the two models are independent). + /// + public const string TargetNodeIdName = "TargetNodeId"; + + /// + /// Finds โ€” or creates โ€” the folder materialized stages are organized under. + /// + /// The server system context. + /// The Server Object. + /// The OpenUSD Scene companion namespace index. + /// Part 1's Server/OpenUSD/Stages folder when + /// the Bindings model is also implemented; pass null for a standalone Server. + /// When supplied it is used as-is, so one connector discovers both the external-stage + /// bindings and the in-server materialized stages (ยง10). + /// The stages folder. + public static FolderState EnsureStagesFolder( + this ISystemContext context, + NodeState server, + ushort ns, + FolderState? part1StagesFolder = null) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (server is null) + { + throw new ArgumentNullException(nameof(server)); + } + if (part1StagesFolder != null) + { + return part1StagesFolder; + } + + FolderState root = EnsureFolder(context, server, OpenUsdSceneRootName, ns); + return EnsureFolder(context, root, StagesFolderName, ns); + } + + /// + /// Resolves the materialized attribute Variable a Part 1 live binding targets (ยง10). + /// + /// + /// Part 1 โ‰ฅ 0.3.0 carries the target two ways and a Server should author both, so + /// path-resolving and NodeId-resolving connectors agree: the optional + /// TargetNodeId names the Variable directly, and the mandatory + /// TargetPrimPath/TargetPropertyName pair resolves to the same Variable. + /// This helper resolves the path form against a materialization result; a caller that + /// already has a NodeId does not need it. + /// + /// The materialization to resolve against. + /// The absolute prim path, for example + /// /Plant/Pumps/P101/Pump/Impeller. + /// The attribute name, for example + /// xformOp:rotateZ. + /// The resolved attribute Variable. + /// true when the target resolved. + public static bool TryResolveBindingTarget( + this UsdMaterializationResult result, + string targetPrimPath, + string targetPropertyName, + out UsdAttributeState? attribute) + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + attribute = null; + if (string.IsNullOrEmpty(targetPrimPath) || string.IsNullOrEmpty(targetPropertyName)) + { + return false; + } + string key = targetPrimPath + "." + targetPropertyName; + if (result.AttributesByPath.TryGetValue(key, out UsdAttributeState? found)) + { + attribute = found; + return true; + } + return false; + } + + /// + /// The NodeId a Part 1 binding should author into its TargetNodeId member for + /// the given materialized attribute (ยง10). + /// + /// The materialization to resolve against. + /// The absolute prim path. + /// The attribute name. + /// The NodeId, or a null NodeId when the target does not resolve. + public static NodeId ResolveBindingTargetNodeId( + this UsdMaterializationResult result, + string targetPrimPath, + string targetPropertyName) + { + return result.TryResolveBindingTarget( + targetPrimPath, targetPropertyName, out UsdAttributeState? attribute) + && attribute != null + ? attribute.NodeId + : NodeId.Null; + } + + /// + /// Stage-aware target resolution: resolves the path form only when the binding names + /// this stage, so a Server hosting several materialized stages never returns a + /// same-path attribute from the wrong stage (ยง10). + /// + /// The materialization to resolve against. + /// The NodeId of the UsdStageType the binding targets + /// (Part 1's TargetStage). A null NodeId fails closed โ€” the stage-aware form + /// requires a stage; use the two-argument overload for a single-stage Server. + /// The absolute prim path. + /// The attribute name. + /// The resolved attribute Variable. + /// true when the binding names this stage and the target resolves. + public static bool TryResolveBindingTarget( + this UsdMaterializationResult result, + NodeId targetStage, + string targetPrimPath, + string targetPropertyName, + out UsdAttributeState? attribute) + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + attribute = null; + // A binding that carries a TargetStage must match this stage exactly; otherwise the + // target belongs to a different stage and this result must not answer for it. + if (targetStage.IsNull || result.Stage.NodeId != targetStage) + { + return false; + } + return result.TryResolveBindingTarget( + targetPrimPath, targetPropertyName, out attribute); + } + + /// + /// Stage-aware target resolution across every materialized stage a Server hosts: the + /// binding's TargetStage selects which stage answers, then the path resolves + /// within it (ยง10). + /// + /// Every materialized stage the Server hosts. + /// The NodeId of the stage the binding targets. + /// The absolute prim path. + /// The attribute name. + /// The resolved attribute Variable. + /// true when exactly the named stage resolves the target. + public static bool TryResolveBindingTarget( + this IEnumerable results, + NodeId targetStage, + string targetPrimPath, + string targetPropertyName, + out UsdAttributeState? attribute) + { + if (results is null) + { + throw new ArgumentNullException(nameof(results)); + } + attribute = null; + foreach (UsdMaterializationResult result in results) + { + if (result != null && + result.TryResolveBindingTarget( + targetStage, targetPrimPath, targetPropertyName, out attribute)) + { + return true; + } + } + return false; + } + + /// + /// The NodeId a Part 1 binding should author into its TargetNodeId member, + /// disambiguated by the binding's TargetStage across every hosted stage (ยง10). + /// + /// Every materialized stage the Server hosts. + /// The NodeId of the stage the binding targets. + /// The absolute prim path. + /// The attribute name. + /// The NodeId, or a null NodeId when nothing resolves in the named stage. + public static NodeId ResolveBindingTargetNodeId( + this IEnumerable results, + NodeId targetStage, + string targetPrimPath, + string targetPropertyName) + { + return results.TryResolveBindingTarget( + targetStage, targetPrimPath, targetPropertyName, out UsdAttributeState? attribute) + && attribute != null + ? attribute.NodeId + : NodeId.Null; + } + + /// + /// Authors Part 1's TargetNodeId onto a supplied binding node so a Server can + /// publish both target forms in one call: the caller has already set the + /// mandatory TargetPrimPath/TargetPropertyName pair, and this resolves the + /// same attribute to a NodeId and writes it as the optional TargetNodeId so + /// NodeId-resolving and path-resolving connectors agree (ยง10). + /// + /// + /// The binding is typed only as a and the member is addressed by + /// BrowseName, so Part 2 authors the Part 1 member without a compile-time dependency on + /// the Part 1 model (ยง4.2 โ€” "Neither model requires the other"). It fails closed: when + /// the path does not resolve to a materialized attribute, nothing is authored and it + /// returns false, so a binding never gains a TargetNodeId naming a node + /// that is not in the address space. + /// + /// The materialization the target lives in. + /// The server system context. + /// The Part 1 live-binding Object to author onto. + /// The absolute prim path. + /// The attribute name. + /// The namespace index of the Part 1 Bindings + /// companion, which owns the TargetNodeId BrowseName. + /// true when the target resolved and TargetNodeId was authored. + public static bool TryAuthorBindingTargetNodeId( + this UsdMaterializationResult result, + ISystemContext context, + NodeState binding, + string targetPrimPath, + string targetPropertyName, + ushort part1NamespaceIndex) + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (binding is null) + { + throw new ArgumentNullException(nameof(binding)); + } + + NodeId target = result.ResolveBindingTargetNodeId(targetPrimPath, targetPropertyName); + if (target.IsNull) + { + return false; + } + SetTargetNodeId(context, binding, part1NamespaceIndex, target); + return true; + } + + /// + /// Stage-aware overload of + /// : + /// the binding's TargetStage selects which of the Server's stages the target is + /// resolved in before the TargetNodeId is authored (ยง10). + /// + /// Every materialized stage the Server hosts. + /// The server system context. + /// The Part 1 live-binding Object to author onto. + /// The NodeId of the stage the binding targets. + /// The absolute prim path. + /// The attribute name. + /// The namespace index of the Part 1 Bindings + /// companion. + /// true when the target resolved and TargetNodeId was authored. + public static bool TryAuthorBindingTargetNodeId( + this IEnumerable results, + ISystemContext context, + NodeState binding, + NodeId targetStage, + string targetPrimPath, + string targetPropertyName, + ushort part1NamespaceIndex) + { + if (results is null) + { + throw new ArgumentNullException(nameof(results)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (binding is null) + { + throw new ArgumentNullException(nameof(binding)); + } + + NodeId target = results.ResolveBindingTargetNodeId( + targetStage, targetPrimPath, targetPropertyName); + if (target.IsNull) + { + return false; + } + SetTargetNodeId(context, binding, part1NamespaceIndex, target); + return true; + } + + /// + /// Writes into the binding's TargetNodeId member, + /// reusing the member when it already exists (a generated Part 1 binding) or creating a + /// PropertyType Variable of DataType NodeId when it does not. + /// + private static void SetTargetNodeId( + ISystemContext context, NodeState binding, ushort part1NamespaceIndex, NodeId target) + { + var browseName = new QualifiedName(TargetNodeIdName, part1NamespaceIndex); + if (binding.FindChild(context, browseName) is BaseVariableState existing) + { + existing.Value = Variant.From(target); + return; + } + + var property = new PropertyState(binding) + { + BrowseName = browseName, + DisplayName = new LocalizedText(TargetNodeIdName), + TypeDefinitionId = Opc.Ua.VariableTypeIds.PropertyType, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HasProperty, + DataType = Opc.Ua.DataTypeIds.NodeId, + ValueRank = ValueRanks.Scalar, + AccessLevel = AccessLevels.CurrentRead, + UserAccessLevel = AccessLevels.CurrentRead, + Value = Variant.From(target) + }; + binding.AddChild(property); + property.NodeId = context.NodeIdFactory.New(context, property); + } + + private static FolderState EnsureFolder( + ISystemContext context, NodeState parent, string name, ushort ns) + { + var browseName = new QualifiedName(name, ns); + var children = new List(); + parent.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + if (child is FolderState existing && existing.BrowseName == browseName) + { + return existing; + } + } + + var folder = new FolderState(parent) + { + BrowseName = browseName, + DisplayName = new LocalizedText(name), + TypeDefinitionId = Opc.Ua.ObjectTypeIds.FolderType, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HasComponent, + EventNotifier = EventNotifiers.None + }; + parent.AddChild(folder); + folder.NodeId = context.NodeIdFactory.New(context, folder); + return folder; + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneExporter.cs b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneExporter.cs new file mode 100644 index 0000000000..b4114bdb75 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneExporter.cs @@ -0,0 +1,503 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Server +{ + /// + /// Reads a materialized address space back into a scene document, the inverse of + /// (draft OPC UA โ€” OpenUSD Scene Materialization ยง7.2). + /// Combined with a .usda writer this closes the ยง7.4 round trip. + /// + public static class UsdSceneExporter + { + /// + /// Exports a materialized scene, recovering time samples from the materialization result so + /// sampled attributes round-trip (ยง7.2). This is the recommended overload after a + /// materialization, since the samples are held by the result rather than readable from the + /// nodes. + /// + /// The server system context. + /// The materialization result to export. + /// The reconstructed composed scene. + public static UsdStage ExportUsdStage( + this ISystemContext context, UsdMaterializationResult result) + { + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } + return ExportUsdStageCore( + context, result.Stage, result.SamplesByNode, result.ConnectionsByNode); + } + + /// + /// Exports a materialized UsdStageType Object into a scene document. + /// + /// The server system context. + /// The materialized stage. + /// + /// The time samples recovered per attribute node (from + /// ), or null when the caller has + /// none โ€” sampled attributes then export their default only. Keyed by node identity because + /// materialized attributes deliberately share the model's placeholder NodeId (ยง7.2). + /// + /// + /// Connections are recovered from each attribute's ConnectionPaths member, which the + /// materializer authors on the node itself, so this overload is now lossless for + /// connections too: authored order, multiplicity, and targets outside the materialized + /// subtree all survive. The browsable UsdConnection edges are used only as a + /// fallback for a stage that was not materialized by this library, where they cannot + /// recover authored order or an out-of-subtree target. + /// + /// The reconstructed composed scene. + public static UsdStage ExportUsdStage( + this ISystemContext context, + UsdStageState stageNode, + IReadOnlyDictionary>? timeSamples = null) + { + return ExportUsdStageCore(context, stageNode, timeSamples, null); + } + + private static UsdStage ExportUsdStageCore( + ISystemContext context, + UsdStageState stageNode, + IReadOnlyDictionary>? timeSamples, + IReadOnlyDictionary>? connectionPaths) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (stageNode is null) + { + throw new ArgumentNullException(nameof(stageNode)); + } + + var stage = new UsdStage(stageNode.BrowseName.Name ?? string.Empty) + { + DefaultPrim = ReadString(stageNode.DefaultPrim) ?? string.Empty, + UpAxis = ReadString(stageNode.UpAxis) ?? "Z", + MetersPerUnit = ReadDouble(stageNode.MetersPerUnit) ?? 1.0, + KilogramsPerUnit = ReadDouble(stageNode.KilogramsPerUnit), + TimeCodesPerSecond = ReadDouble(stageNode.TimeCodesPerSecond), + StartTimeCode = ReadDouble(stageNode.StartTimeCode), + EndTimeCode = ReadDouble(stageNode.EndTimeCode), + RootLayerIdentifier = ReadString(stageNode.RootLayerIdentifier) ?? string.Empty, + Documentation = ReadString(stageNode.Documentation) ?? string.Empty + }; + + // Two-pass export: an attribute connection can point at any attribute in the + // stage (ยง5.4), so the whole prim tree and a NodeId -> SdfPath index are built + // first, then connections are resolved once every attribute's path is known โ€” + // mirroring how the materializer resolves references in a second pass. + var attributePaths = new Dictionary(); + var pendingConnections = + new List<(UsdAttribute Attribute, UsdAttributeState Node)>(); + + foreach (UsdPrimState child in ChildrenOfType(context, stageNode)) + { + string childPath = "/" + (child.BrowseName.Name ?? string.Empty); + stage.AddRootPrim(ExportPrim( + context, child, childPath, attributePaths, pendingConnections, + timeSamples, connectionPaths)); + } + + ResolveExportedConnections(context, pendingConnections, attributePaths, connectionPaths); + return stage; + } + + private static UsdPrim ExportPrim( + ISystemContext context, + UsdPrimState primNode, + string primPath, + Dictionary attributePaths, + List<(UsdAttribute Attribute, UsdAttributeState Node)> pendingConnections, + IReadOnlyDictionary>? timeSamples, + IReadOnlyDictionary>? connectionPaths) + { + string name = primNode.BrowseName.Name ?? string.Empty; + var prim = new UsdPrim(name, ReadString(primNode.TypeName) ?? string.Empty) + { + Specifier = ReadEnum(primNode.Specifier, UsdSpecifierEnum.Def), + Kind = ReadEnum(primNode.Kind, UsdPrimKindEnum.Unspecified), + Active = ReadBool(primNode.Active) ?? true, + Instanceable = ReadBool(primNode.Instanceable) ?? false, + Documentation = ReadString(primNode.Documentation) ?? string.Empty + }; + + foreach (UsdAttributeState attributeNode in + ChildrenOfType(context, primNode)) + { + UsdAttribute attribute = ExportAttribute(attributeNode, timeSamples); + prim.Attributes.Add(attribute); + // Index the attribute by NodeId so any connection targeting it can be + // mapped back to ., the same key the materializer + // resolved connections against. + string attributePath = + primPath + "." + (attributeNode.BrowseName.Name ?? string.Empty); + attributePaths[attributeNode.NodeId] = attributePath; + pendingConnections.Add((attribute, attributeNode)); + } + foreach (UsdRelationshipState relationshipNode in + ChildrenOfType(context, primNode)) + { + prim.Relationships.Add(ExportRelationship(relationshipNode)); + } + + ExportComposition(context, primNode, prim); + ExportVariantSets(context, primNode, prim, timeSamples, connectionPaths); + ExportAppliedSchemas(context, primNode, prim); + ExportMetadata(context, primNode, prim); + + foreach (UsdPrimState childNode in ChildrenOfType(context, primNode)) + { + string childPath = primPath + "/" + (childNode.BrowseName.Name ?? string.Empty); + prim.AddChild(ExportPrim( + context, childNode, childPath, attributePaths, pendingConnections, + timeSamples, connectionPaths)); + } + return prim; + } + + private static UsdAttribute ExportAttribute( + UsdAttributeState attributeNode, + IReadOnlyDictionary>? timeSamples) + { + string name = attributeNode.BrowseName.Name ?? string.Empty; + // The UsdTypeName annotation is what makes the ยง6.2 mapping reversible: it records + // the exact SdfValueTypeName even where several USD types share one DataType. + string typeName = ReadString(attributeNode.UsdTypeName) ?? string.Empty; + var attribute = new UsdAttribute(name, typeName) + { + Variability = ReadEnum(attributeNode.Variability, UsdVariabilityEnum.Varying), + Custom = ReadBool(attributeNode.Custom) ?? false, + Interpolation = ReadString(attributeNode.Interpolation), + // ยง9 Mode A is a per-attribute property independent of whether the Server + // retains history: the materializer grants CurrentWrite to every live + // attribute but only sets Historizing when it also keeps the timeline. + // Infer liveness from the write access, so a Mode A attribute that is not + // historized still exports as live. + Live = (attributeNode.AccessLevel & Opc.Ua.AccessLevels.CurrentWrite) != 0, + // BoxingBehavior.Legacy unwraps an ArrayOf/MatrixOf to the T[]/T[,] + // shapes Decoerce consumes, so array, tuple and matrix values survive the + // round trip instead of being stringified as an opaque struct (ยง7.2). + Value = UsdValueCoercion.Decoerce( + attributeNode.Value.AsBoxedObject(Variant.BoxingBehavior.Legacy)) + }; + + // ยง7.2: time samples are held by the materialization result, not the node, so recover + // them from the caller's node-keyed map. The authored default stays in Value above; + // the two are independent (an attribute may have either, both or neither). + if (timeSamples != null && + timeSamples.TryGetValue(attributeNode, out IReadOnlyList? samples)) + { + foreach (UsdTimeSample sample in samples) + { + attribute.TimeSamples[sample.TimeCode] = sample.Value; + } + } + return attribute; + } + + private static void ResolveExportedConnections( + ISystemContext context, + List<(UsdAttribute Attribute, UsdAttributeState Node)> pending, + Dictionary attributePaths, + IReadOnlyDictionary>? connectionPaths) + { + NodeId connectionTypeId = ExpandedNodeId.ToNodeId( + Opc.Ua.OpenUsdScene.ReferenceTypeIds.UsdConnection, context.NamespaceUris); + var references = new List(); + foreach ((UsdAttribute attribute, UsdAttributeState node) in pending) + { + // Authoritative: ConnectionPaths is authored on the node itself and carries every + // connection in order, including a target outside the materialized subtree that + // has no browsable edge. It is the connection counterpart of a relationship's + // TargetPaths (ยง5.4, ยง5.5, ยง7.4), so โ€” unlike the side channel below โ€” it works + // for a bare stage node too. + if (node.ConnectionPaths?.Value is { } declared && declared.Count > 0) + { + foreach (string path in declared) + { + attribute.Connections.Add(path); + } + continue; + } + + // Compatibility: a materialization result may still carry the recorded order for + // a stage materialized before ConnectionPaths existed. + if (connectionPaths != null && + connectionPaths.TryGetValue(node, out IReadOnlyList? authored)) + { + foreach (string path in authored) + { + attribute.Connections.Add(path); + } + continue; + } + + // Fallback (a bare stage node with no recorded side channel): rebuild from the + // browsable UsdConnection edges. Reference enumeration order is not the authored + // order, so sort by target SdfPath to make the exported sequence deterministic; + // targets outside the subtree cannot be recovered on this path. + references.Clear(); + node.GetReferences(context, references, connectionTypeId, false); + var resolved = new List(); + foreach (IReference reference in references) + { + NodeId targetId = + ExpandedNodeId.ToNodeId(reference.TargetId, context.NamespaceUris); + if (!targetId.IsNull && + attributePaths.TryGetValue(targetId, out string? path)) + { + resolved.Add(path); + } + } + resolved.Sort(StringComparer.Ordinal); + foreach (string path in resolved) + { + attribute.Connections.Add(path); + } + } + } + + private static UsdRelationship ExportRelationship(UsdRelationshipState relationshipNode) + { + var relationship = new UsdRelationship(relationshipNode.BrowseName.Name ?? string.Empty) + { + Custom = ReadBool(relationshipNode.Custom) ?? false + }; + // TargetPaths is authoritative: it also covers targets that lie outside the + // materialized subtree and therefore have no browsable edge (ยง5.5). + if (relationshipNode.TargetPaths?.Value is { } paths) + { + foreach (string path in paths) + { + relationship.Targets.Add(path); + } + } + return relationship; + } + + private static void ExportComposition( + ISystemContext context, UsdPrimState primNode, UsdPrim prim) + { + if (primNode.Composition == null) + { + return; + } + foreach (UsdCompositionArcState arcNode in + ChildrenOfType(context, primNode.Composition)) + { + var arc = new UsdCompositionArc(ReadEnum(arcNode.ArcKind, UsdArcKindEnum.Reference)) + { + AssetPath = ReadString(arcNode.AssetPath) ?? string.Empty, + PrimPath = ReadString(arcNode.PrimPath) ?? string.Empty, + ListPosition = ReadEnum(arcNode.ListPosition, UsdListOpTypeEnum.Explicit), + VariantSet = ReadString(arcNode.VariantSet) ?? string.Empty, + VariantSelection = ReadString(arcNode.VariantSelection) ?? string.Empty + }; + prim.Composition.Add(arc); + } + } + + private static void ExportVariantSets( + ISystemContext context, UsdPrimState primNode, UsdPrim prim, + IReadOnlyDictionary>? timeSamples, + IReadOnlyDictionary>? connectionPaths) + { + if (primNode.VariantSets == null) + { + return; + } + foreach (UsdVariantSetState setNode in + ChildrenOfType(context, primNode.VariantSets)) + { + var variantSet = new UsdVariantSet( + ReadString(setNode.SetName) ?? setNode.BrowseName.Name ?? string.Empty, + ReadString(setNode.Selection) ?? string.Empty); + + // ยง5.6: recover every authored branch (a UsdPrimType child of the set) + // as a prim-shaped branch body, exporting each with the same machinery as a + // top-level prim. A branch is self-contained, so its attribute index and + // connections resolve within the branch alone. + foreach (UsdPrimState branchNode in ChildrenOfType(context, setNode)) + { + var branchPaths = new Dictionary(); + var branchPending = + new List<(UsdAttribute Attribute, UsdAttributeState Node)>(); + string branchPath = "/" + (branchNode.BrowseName.Name ?? string.Empty); + UsdPrim branch = ExportPrim( + context, branchNode, branchPath, branchPaths, branchPending, + timeSamples, connectionPaths); + ResolveExportedConnections( + context, branchPending, branchPaths, connectionPaths); + variantSet.Variants.Add(branch); + } + + prim.VariantSets.Add(variantSet); + } + } + + private static void ExportAppliedSchemas( + ISystemContext context, UsdPrimState primNode, UsdPrim prim) + { + if (primNode.AppliedSchemas == null) + { + return; + } + foreach (UsdApiSchemaState schemaNode in + ChildrenOfType(context, primNode.AppliedSchemas)) + { + // The portable georeference AddIns are additive provenance dual-authored by + // the materializer; they carry no opinion the vendor schema does not, so they + // are not re-emitted (Annex B.4). + if (schemaNode is UsdGeoreferenceApiState or UsdGlobeAnchorApiState) + { + continue; + } + string schemaName = ReadString(schemaNode.SchemaName) + ?? schemaNode.BrowseName.Name + ?? string.Empty; + var schema = new UsdApiSchema(schemaName); + if (schemaNode is UsdCollectionAPIState collection) + { + schema.ExpansionRule = ReadString(collection.ExpansionRule) ?? string.Empty; + } + prim.ApiSchemas.Add(schema); + } + } + + private static void ExportMetadata( + ISystemContext context, UsdPrimState primNode, UsdPrim prim) + { + if (primNode.Metadata == null) + { + return; + } + ReadMetadataFolder(context, primNode.Metadata, prim.Metadata); + } + + /// + /// Recovers a Metadata/ folder into a metadata dictionary (ยง6.3), the inverse of the + /// materializer's typed authoring. A leaf Property is read back in its own type โ€” Legacy + /// boxing unwraps an ArrayOf<T>/MatrixOf<T> and Decoerce + /// reverses the ยง6.2 coercion, so a value round-trips as itself rather than an opaque box + /// (the same fix as the attribute-value path). A nested Metadata folder is recovered as a + /// nested dictionary, so structured customData keeps its authored nesting to any depth. + /// + private static void ReadMetadataFolder( + ISystemContext context, NodeState folder, IDictionary into) + { + var children = new List(); + folder.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + string key = child.BrowseName.Name ?? string.Empty; + if (key.Length == 0) + { + continue; + } + switch (child) + { + case BaseVariableState property: + into[key] = UsdValueCoercion.Decoerce( + property.Value.AsBoxedObject(Variant.BoxingBehavior.Legacy)); + break; + case FolderState subFolder: + var nested = new Dictionary(StringComparer.Ordinal); + ReadMetadataFolder(context, subFolder, nested); + into[key] = nested; + break; + } + } + } + + private static IEnumerable ChildrenOfType(ISystemContext context, NodeState parent) + where T : NodeState + { + var children = new List(); + parent.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + if (child is T typed) + { + yield return typed; + } + } + } + + private static string? ReadString(BaseVariableState? node) + { + if (node == null) + { + return null; + } + return node.Value.TryGetValue(out string value) ? value : null; + } + + private static double? ReadDouble(BaseVariableState? node) + { + if (node == null) + { + return null; + } + return node.Value.TryGetValue(out double value) ? value : null; + } + + private static bool? ReadBool(BaseVariableState? node) + { + if (node == null) + { + return null; + } + return node.Value.TryGetValue(out bool value) ? value : null; + } + + private static TEnum ReadEnum(BaseVariableState? node, TEnum fallback) + where TEnum : struct, Enum + { + if (node == null) + { + return fallback; + } + if (node.Value.TryGetValue(out int raw) && + Enum.IsDefined(typeof(TEnum), raw)) + { + return (TEnum)Enum.ToObject(typeof(TEnum), raw); + } + return node.Value.TryGetValue(out TEnum typed) ? typed : fallback; + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs new file mode 100644 index 0000000000..2922244d79 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.Properties.cs @@ -0,0 +1,811 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Globalization; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Server +{ + public static partial class UsdSceneMaterializer + { + private const string kCesiumLatitude = "cesium:anchor:latitude"; + private const string kCesiumLongitude = "cesium:anchor:longitude"; + private const string kCesiumHeight = "cesium:anchor:height"; + private const string kCesiumGeoreference = "CesiumGeoreferencePrim"; + private const string kCesiumGlobeAnchor = "CesiumGlobeAnchorAPI"; + + /// + /// WGS84 โ€” the CRS Cesium anchors are expressed in. + /// + private const int kEpsgWgs84 = 4326; + + /// + /// Whether a prim is a *typed* Cesium georeference or globe-anchor prim + /// (def CesiumGeoreferencePrim โ€ฆ / def CesiumGlobeAnchorAPI โ€ฆ; ยง5.8, + /// Annex B.1). Such a prim needs its AppliedSchemas/ folder created โ€” and the + /// portable anchor dual-authored โ€” even when it carries no applied API schema. + /// + private static bool IsGeoreferenceTypedPrim(UsdPrim prim) + { + return string.Equals(prim.TypeName, kCesiumGeoreference, StringComparison.Ordinal) || + string.Equals(prim.TypeName, kCesiumGlobeAnchor, StringComparison.Ordinal); + } + + private static UsdAttributeState MaterializeAttribute( + ISystemContext context, + UsdPrimState primNode, + UsdAttribute attribute, + ushort ns, + UsdMaterializationOptions options) + { + UsdAttributeState node = primNode.AddxUsdAttribute_( + context, new QualifiedName(attribute.Name, ns)); + + UsdValueTypeMapping mapping = UsdValueTypeMap.Map( + attribute.TypeName, context.NamespaceUris); + uint components = UsdValueTypeMap.ComponentCount(attribute.TypeName); + + node.DataType = mapping.DataTypeId; + node.ValueRank = mapping.ValueRank; + if (mapping.ArrayDimensions != null) + { + node.ArrayDimensions = mapping.ArrayDimensions; + } + + if (UsdValueCoercion.TryCoerce(attribute.Value, mapping, components, out Variant variant)) + { + node.Value = variant; + } + + // The exact SdfValueTypeName is always retained, which is what makes the ยง6.2 + // mapping reversible even where several USD types share one OPC UA DataType. + node.CreateOrReplaceUsdTypeName(context, null!).Value = attribute.TypeName; + node.CreateOrReplaceVariability(context, null!).Value = attribute.Variability; + if (attribute.Custom) + { + node.CreateOrReplaceCustom(context, null!).Value = true; + } + if (!string.IsNullOrEmpty(attribute.Namespace)) + { + node.CreateOrReplaceNamespace(context, null!).Value = attribute.Namespace; + } + if (!string.IsNullOrEmpty(attribute.Interpolation)) + { + node.CreateOrReplaceInterpolation(context, null!).Value = attribute.Interpolation; + } + + if (attribute.Live) + { + // Mode A (ยง9): the Value is server-maintained and time-varying, so a + // Subscription delivers changes and โ€” where the Server retains it โ€” + // HistoryRead exposes the value timeline. + node.AccessLevel = AccessLevels.CurrentReadOrWrite; + node.UserAccessLevel = AccessLevels.CurrentReadOrWrite; + if (options.HistorizeLiveAttributes) + { + node.Historizing = true; + node.AccessLevel |= AccessLevels.HistoryRead; + node.UserAccessLevel |= AccessLevels.HistoryRead; + } + } + + // ยง7.1 step 3: an attribute that carries time samples materializes its authored + // default as Value (already set above) and exposes the samples through + // HistoricalAccess. Time samples make the node historizing independently of Live โ€” + // the samples ARE the retained timeline โ€” so mark it read + HistoryRead. The samples + // themselves are recorded by the caller into the materialization result, from where a + // hosting Server binds them to its historian and the exporter recovers them (ยง7.2). + if (attribute.TimeSamples.Count > 0) + { + node.Historizing = true; + node.AccessLevel |= AccessLevels.CurrentRead | AccessLevels.HistoryRead; + node.UserAccessLevel |= AccessLevels.CurrentRead | AccessLevels.HistoryRead; + } + + return node; + } + + private static UsdRelationshipState MaterializeRelationship( + ISystemContext context, + UsdPrimState primNode, + UsdRelationship relationship, + ushort ns) + { + UsdRelationshipState node = primNode.AddxUsdRelationship_( + context, new QualifiedName(relationship.Name, ns)); + + var paths = new string[relationship.Targets.Count]; + relationship.Targets.CopyTo(paths, 0); + node.CreateOrReplaceTargetPaths(context, null!).Value = paths; + if (relationship.Custom) + { + node.CreateOrReplaceCustom(context, null!).Value = true; + } + return node; + } + + private static void ResolveRelationshipTargets( + ISystemContext context, + List<(UsdRelationshipState Node, UsdRelationship Source)> pending, + Dictionary prims, + Dictionary attributes) + { + foreach ((UsdRelationshipState node, UsdRelationship source) in pending) + { + var resolved = new List(source.Targets.Count); + foreach (string path in source.Targets) + { + NodeState? target = null; + if (prims.TryGetValue(path, out UsdPrimState? prim)) + { + target = prim; + } + else if (attributes.TryGetValue(path, out UsdAttributeState? attribute)) + { + target = attribute; + } + if (target == null) + { + // ยง5.5 / ยง7.1 step 4 require the targets to stay ordered, so Targets + // must correspond positionally to TargetPaths. A target outside the + // materialized subtree keeps full fidelity through TargetPaths; here it + // takes a null-NodeId placeholder so the two arrays remain index-aligned + // (entry i of one describes the same target as entry i of the other) and + // only resolvable targets additionally become browsable edges. + resolved.Add(NodeId.Null); + continue; + } + resolved.Add(target.NodeId); + node.AddReference( + ExpandedNodeId.ToNodeId( + Opc.Ua.OpenUsdScene.ReferenceTypeIds.UsdRelationshipTarget, + context.NamespaceUris), + false, + target.NodeId); + } + node.CreateOrReplaceTargets(context, null!).Value = resolved.ToArray(); + } + } + + private static void ResolveConnections( + ISystemContext context, + List<(UsdAttributeState Node, UsdAttribute Source)> pending, + Dictionary attributes) + { + NodeId connectionTypeId = ExpandedNodeId.ToNodeId( + Opc.Ua.OpenUsdScene.ReferenceTypeIds.UsdConnection, context.NamespaceUris); + foreach ((UsdAttributeState node, UsdAttribute source) in pending) + { + // ConnectionPaths is the connection counterpart of UsdRelationshipType's + // TargetPaths (ยง5.4/ยง5.5): it carries every authored SdfPath in order, so a + // connection whose target lies outside the materialized subtree โ€” and therefore + // has no browsable edge โ€” still survives an export, and authored order and + // multiplicity are preserved on the node itself rather than out of band. + var authored = new string[source.Connections.Count]; + source.Connections.CopyTo(authored, 0); + node.CreateOrReplaceConnectionPaths(context, null!).Value = authored; + + // Materialized attributes deliberately share the model's placeholder NodeId + // (xUsdAttribute_, i=6023), so two connections authored on one attribute can + // resolve to the same target NodeId. A forward reference is keyed by + // (type, isInverse, target), so adding that target twice would throw; dedupe the + // browsable edges by target NodeId. These edges exist only for ยง5.4 + // browsability โ€” ConnectionPaths above is what an export relies on. + var linked = new HashSet(); + foreach (string path in source.Connections) + { + if (attributes.TryGetValue(path, out UsdAttributeState? target) && + linked.Add(target.NodeId)) + { + node.AddReference(connectionTypeId, false, target.NodeId); + } + } + } + } + + private static void MaterializeComposition( + ISystemContext context, UsdPrimState primNode, UsdPrim prim, ushort ns) + { + FolderState folder = primNode.CreateOrReplaceComposition(context, null!); + EnsureNodeId(context, folder); + + int index = 0; + foreach (UsdCompositionArc arc in prim.Composition) + { + // string.Create(IFormatProvider, ...) is .NET 6+; concatenation with an + // invariant-culture index keeps the Server compiling on net472/net48/ + // netstandard2.1 while staying culture-independent. + string name = arc.ArcKind.ToString() + "_" + + index.ToString(CultureInfo.InvariantCulture); + index++; + UsdCompositionArcState node = context.CreateInstanceOfUsdCompositionArcType( + folder, new QualifiedName(name, ns)); + Attach(context, folder, node, Opc.Ua.ReferenceTypeIds.HasComponent); + + node.CreateOrReplaceArcKind(context, null!).Value = arc.ArcKind; + if (!string.IsNullOrEmpty(arc.AssetPath)) + { + node.CreateOrReplaceAssetPath(context, null!).Value = arc.AssetPath; + } + if (!string.IsNullOrEmpty(arc.PrimPath)) + { + node.CreateOrReplacePrimPath(context, null!).Value = arc.PrimPath; + } + node.CreateOrReplaceListPosition(context, null!).Value = arc.ListPosition; + if (!string.IsNullOrEmpty(arc.VariantSet)) + { + node.CreateOrReplaceVariantSet(context, null!).Value = arc.VariantSet; + } + if (!string.IsNullOrEmpty(arc.VariantSelection)) + { + node.CreateOrReplaceVariantSelection(context, null!).Value = arc.VariantSelection; + } + } + } + + private static void MaterializeVariantSets( + ISystemContext context, UsdPrimState primNode, UsdPrim prim, ushort ns, + UsdMaterializationOptions options, UsdMaterializationRecorder recorder) + { + FolderState folder = primNode.CreateOrReplaceVariantSets(context, null!); + EnsureNodeId(context, folder); + + foreach (UsdVariantSet variantSet in prim.VariantSets) + { + UsdVariantSetState node = context.CreateInstanceOfUsdVariantSetType( + folder, new QualifiedName(variantSet.SetName, ns)); + Attach(context, folder, node, Opc.Ua.ReferenceTypeIds.HasComponent); + node.CreateOrReplaceSetName(context, null!).Value = variantSet.SetName; + node.CreateOrReplaceSelection(context, null!).Value = variantSet.Selection; + + // ยง5.6: a UsdVariantSetType exposes its variant *branches* as + // OptionalPlaceholder children (UsdPrimType). Every captured branch is authored โ€” + // not only the selection โ€” so the full branch structure the model defines is + // materialized. A set that recorded only a resolved Selection (no captured + // branches) authors no branch rather than inventing one from the selection name + // (fail closed); its selection is still carried by the Selection property above. + foreach (UsdPrim branch in variantSet.Variants) + { + UsdPrimState branchNode = node.AddxVariant_( + context, new QualifiedName(branch.Name, ns)); + // AddxVariant_ leaves the type's placeholder NodeId (i=6055); force a fresh + // per-instance NodeId so branches on different sets never collide, matching + // how the CreateInstanceOf* factories mint instance ids. + context.AssignInstanceNodeId(branchNode); + + // A branch body is prim-shaped and self-contained: its relationships and + // connections address paths *within the branch*, so it is populated and + // resolved against an isolated local index. That keeps branch-internal edges + // working while never leaking branch paths into the composed-scene index the + // ยง7.4 round trip and Part 1 bindings resolve against. Sampled and connected + // branch attributes are still recorded into the shared recorder (node-keyed) + // so they round-trip through the exporter. + var branchPrims = new Dictionary(StringComparer.Ordinal); + var branchAttributes = + new Dictionary(StringComparer.Ordinal); + var branchRelationships = new List<(UsdRelationshipState, UsdRelationship)>(); + var branchConnections = new List<(UsdAttributeState, UsdAttribute)>(); + + PopulatePrimNode( + context, branchNode, branch, "/" + branch.Name, ns, options, + branchPrims, branchAttributes, branchRelationships, branchConnections, + recorder); + + ResolveRelationshipTargets( + context, branchRelationships, branchPrims, branchAttributes); + ResolveConnections(context, branchConnections, branchAttributes); + } + } + } + + private static void MaterializeMetadata( + ISystemContext context, UsdPrimState primNode, UsdPrim prim, ushort ns) + { + FolderState folder = primNode.CreateOrReplaceMetadata(context, null!); + EnsureNodeId(context, folder); + MaterializeMetadataEntries(context, folder, prim.Metadata, ns); + } + + /// + /// Populates a Metadata/ folder from a set of authored entries (ยง6.3). Each scalar + /// or array entry becomes a Property carrying its value in its own type โ€” chosen per value + /// rather than stringified โ€” so a client reads the authored kind and the exporter recovers + /// it. A nested dictionary becomes a nested Metadata folder, so structured customData + /// is browsable as authored; this recurses to whatever depth the source nests. + /// + private static void MaterializeMetadataEntries( + ISystemContext context, + NodeState folder, + IEnumerable> entries, + ushort ns) + { + foreach (KeyValuePair entry in entries) + { + if (string.IsNullOrEmpty(entry.Key)) + { + continue; + } + + if (TryAsNestedDictionary( + entry.Value, out IEnumerable> nested)) + { + var subFolder = new FolderState(folder) + { + BrowseName = new QualifiedName(entry.Key, ns), + DisplayName = new LocalizedText(entry.Key), + TypeDefinitionId = Opc.Ua.ObjectTypeIds.FolderType, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.Organizes + }; + folder.AddChild(subFolder); + subFolder.NodeId = context.NodeIdFactory.New(context, subFolder); + MaterializeMetadataEntries(context, subFolder, nested, ns); + continue; + } + + var property = new PropertyState(folder) + { + BrowseName = new QualifiedName(entry.Key, ns), + DisplayName = new LocalizedText(entry.Key), + TypeDefinitionId = Opc.Ua.VariableTypeIds.PropertyType, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HasProperty, + DataType = Opc.Ua.DataTypeIds.BaseDataType, + ValueRank = ValueRanks.Scalar + }; + if (entry.Value != null) + { + if (TryCoerceMetadataValue( + entry.Value, out Variant variant, out NodeId dataType, out int valueRank)) + { + // A recognised value keeps its type through the round trip, so the + // exporter recovers the authored object rather than an opaque string (ยง6.3). + property.Value = variant; + property.DataType = dataType; + property.ValueRank = valueRank; + } + else + { + // A value of no representable type is carried as its invariant textual + // form rather than dropped โ€” the ยง6.3 last resort, not a typed guess. + property.Value = Variant.From( + entry.Value as string ?? + Convert.ToString(entry.Value, CultureInfo.InvariantCulture) ?? + string.Empty); + property.DataType = Opc.Ua.DataTypeIds.String; + } + } + folder.AddChild(property); + property.NodeId = context.NodeIdFactory.New(context, property); + } + } + + /// + /// Whether a metadata value is a nested dictionary (structured customData), and if so + /// its entries. Only string-keyed dictionaries qualify; anything else is a leaf value. + /// + private static bool TryAsNestedDictionary( + object? value, out IEnumerable> entries) + { + switch (value) + { + case IReadOnlyDictionary readOnly: + entries = readOnly; + return true; + case IDictionary readWrite: + entries = readWrite; + return true; + default: + entries = Array.Empty>(); + return false; + } + } + + /// + /// Chooses a Variant, DataType and ValueRank for a leaf metadata value from its CLR type, + /// so a scalar keeps its exact type and an array keeps its (inferred) element type through + /// the materializeโ†’export round trip (ยง6.3). Returns false for a scalar whose type is + /// not representable, so the caller carries its textual form instead of guessing. + /// + private static bool TryCoerceMetadataValue( + object value, out Variant variant, out NodeId dataType, out int valueRank) + { + valueRank = ValueRanks.Scalar; + switch (value) + { + case bool v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Boolean; return true; + case sbyte v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.SByte; return true; + case byte v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Byte; return true; + case short v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Int16; return true; + case ushort v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.UInt16; return true; + case int v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Int32; return true; + case uint v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.UInt32; return true; + case long v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Int64; return true; + case ulong v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.UInt64; return true; + case float v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Float; return true; + case double v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.Double; return true; + case string v: variant = Variant.From(v); dataType = Opc.Ua.DataTypeIds.String; return true; + case System.Collections.IEnumerable sequence: + return TryCoerceMetadataArray(sequence, out variant, out dataType, out valueRank); + default: + variant = default; + dataType = Opc.Ua.DataTypeIds.String; + return false; + } + } + + /// + /// Coerces a metadata array or list into a typed 1-D Variant, inferring the element type + /// from the first non-null element. A homogeneous numeric or boolean array keeps its + /// element type; an empty or mixed sequence is carried as a string array so it round-trips + /// as a sequence rather than being dropped (ยง6.3, fail closed โ€” no numeric guess). + /// + private static bool TryCoerceMetadataArray( + System.Collections.IEnumerable sequence, + out Variant variant, + out NodeId dataType, + out int valueRank) + { + valueRank = ValueRanks.OneDimension; + var items = new List(); + foreach (object? item in sequence) + { + items.Add(item); + } + object? first = null; + foreach (object? item in items) + { + if (item != null) + { + first = item; + break; + } + } + switch (first) + { + case bool _ when TryFillArray(items, v => Convert.ToBoolean(v, CultureInfo.InvariantCulture), out bool[] values): + variant = Variant.From((ArrayOf)values); + dataType = Opc.Ua.DataTypeIds.Boolean; + return true; + case sbyte _ or short _ or int _ when TryFillArray(items, v => Convert.ToInt32(v, CultureInfo.InvariantCulture), out int[] values): + variant = Variant.From((ArrayOf)values); + dataType = Opc.Ua.DataTypeIds.Int32; + return true; + case long _ or uint _ when TryFillArray(items, v => Convert.ToInt64(v, CultureInfo.InvariantCulture), out long[] values): + variant = Variant.From((ArrayOf)values); + dataType = Opc.Ua.DataTypeIds.Int64; + return true; + case float _ or double _ when TryFillArray(items, v => Convert.ToDouble(v, CultureInfo.InvariantCulture), out double[] values): + variant = Variant.From((ArrayOf)values); + dataType = Opc.Ua.DataTypeIds.Double; + return true; + default: + var strings = new string[items.Count]; + for (int i = 0; i < items.Count; i++) + { + strings[i] = items[i] as string ?? + Convert.ToString(items[i], CultureInfo.InvariantCulture) ?? + string.Empty; + } + variant = Variant.From((ArrayOf)strings); + dataType = Opc.Ua.DataTypeIds.String; + return true; + } + } + + /// + /// Fills a typed array by converting every element with , failing + /// closed if any element cannot be converted so a heterogeneous array falls back to text. + /// + /// The element type of the array being filled. + private static bool TryFillArray( + List items, Func convert, out T[] result) + { + var array = new T[items.Count]; + for (int i = 0; i < items.Count; i++) + { + object? item = items[i]; + if (item == null) + { + result = Array.Empty(); + return false; + } + try + { + array[i] = convert(item); + } + catch (Exception exception) when ( + exception is InvalidCastException or FormatException or OverflowException) + { + result = Array.Empty(); + return false; + } + } + result = array; + return true; + } + + private static void MaterializeAppliedSchemas( + ISystemContext context, + UsdPrimState primNode, + UsdPrim prim, + ushort ns, + UsdMaterializationOptions options) + { + FolderState folder = primNode.CreateOrReplaceAppliedSchemas(context, null!); + EnsureNodeId(context, folder); + + bool sawGeoreference = false; + bool sawGlobeAnchor = false; + + foreach (UsdApiSchema schema in prim.ApiSchemas) + { + string browseName = string.IsNullOrEmpty(schema.InstanceName) + ? schema.FamilyName + : schema.FamilyName + "_" + schema.InstanceName; + + UsdApiSchemaState node; + if (string.Equals(schema.FamilyName, "CollectionAPI", StringComparison.Ordinal)) + { + UsdCollectionAPIState collection = context.CreateInstanceOfUsdCollectionAPIType( + folder, new QualifiedName(browseName, ns)); + if (!string.IsNullOrEmpty(schema.ExpansionRule)) + { + collection.CreateOrReplaceExpansionRule(context, null!).Value = + schema.ExpansionRule; + } + node = collection; + } + else + { + // An unknown API schema is never dropped: it degrades to a generic + // UsdApiSchemaType AddIn carrying its SchemaName (ยง8.4). + node = context.CreateInstanceOfUsdApiSchemaType( + folder, new QualifiedName(browseName, ns)); + } + + Attach(context, folder, node, Opc.Ua.ReferenceTypeIds.HasAddIn); + node.CreateOrReplaceSchemaName(context, null!).Value = schema.SchemaName; + + sawGeoreference |= string.Equals( + schema.FamilyName, kCesiumGeoreference, StringComparison.Ordinal); + sawGlobeAnchor |= string.Equals( + schema.FamilyName, kCesiumGlobeAnchor, StringComparison.Ordinal); + } + + // A real Cesium stage authors the georeference as a *typed* prim + // (def CesiumGeoreferencePrim "World" { ... }; ยง5.8, Annex B.1), which the reader + // records in prim.TypeName rather than in ApiSchemas. The apiSchemas spelling used + // by the Annex B.3 snippet is honoured above; either spelling must trigger the + // portable dual-authoring so a generic client always finds a vendor-neutral anchor. + sawGeoreference |= string.Equals( + prim.TypeName, kCesiumGeoreference, StringComparison.Ordinal); + sawGlobeAnchor |= string.Equals( + prim.TypeName, kCesiumGlobeAnchor, StringComparison.Ordinal); + + if (!options.DualAuthorPortableGeoreference) + { + return; + } + // A materializer that recognises a vendor georeference schema should additionally + // populate the portable schema, so a generic client obtains the anchor from one + // well-known type while a vendor-aware client still reads the native one (ยง5.8). + if (sawGeoreference) + { + AuthorPortableGeoreference(context, folder, prim, ns); + } + if (sawGlobeAnchor) + { + AuthorPortableGlobeAnchor(context, folder, prim, ns); + } + } + + private static void AuthorPortableGeoreference( + ISystemContext context, FolderState folder, UsdPrim prim, ushort ns) + { + if (!TryReadAnchor(prim, out double latitude, out double longitude, out double height)) + { + return; + } + UsdGeoreferenceApiState node = context.CreateInstanceOfUsdGeoreferenceApiType( + folder, new QualifiedName(BrowseNames.UsdGeoreferenceApiType, ns)); + Attach(context, folder, node, Opc.Ua.ReferenceTypeIds.HasAddIn); + node.CreateOrReplaceSchemaName(context, null!).Value = BrowseNames.UsdGeoreferenceApiType; + node.CreateOrReplaceLatitude(context, null!).Value = latitude; + node.CreateOrReplaceLongitude(context, null!).Value = longitude; + node.CreateOrReplaceHeight(context, null!).Value = height; + node.CreateOrReplaceEpsgCode(context, null!).Value = kEpsgWgs84; + node.CreateOrReplaceTangentPlane(context, null!).Value = "ENU"; + } + + private static void AuthorPortableGlobeAnchor( + ISystemContext context, FolderState folder, UsdPrim prim, ushort ns) + { + if (!TryReadAnchor(prim, out double latitude, out double longitude, out double height)) + { + return; + } + UsdGlobeAnchorApiState node = context.CreateInstanceOfUsdGlobeAnchorApiType( + folder, new QualifiedName(BrowseNames.UsdGlobeAnchorApiType, ns)); + Attach(context, folder, node, Opc.Ua.ReferenceTypeIds.HasAddIn); + node.CreateOrReplaceSchemaName(context, null!).Value = BrowseNames.UsdGlobeAnchorApiType; + node.CreateOrReplaceLatitude(context, null!).Value = latitude; + node.CreateOrReplaceLongitude(context, null!).Value = longitude; + node.CreateOrReplaceHeight(context, null!).Value = height; + } + + private static bool TryReadAnchor( + UsdPrim prim, out double latitude, out double longitude, out double height) + { + latitude = 0.0; + longitude = 0.0; + height = 0.0; + bool haveLatitude = false; + bool haveLongitude = false; + bool haveHeight = false; + + foreach (UsdAttribute attribute in prim.Attributes) + { + if (string.Equals(attribute.Name, kCesiumLatitude, StringComparison.Ordinal) && + TryToDouble(attribute.Value, out double lat)) + { + latitude = lat; + haveLatitude = true; + } + else if (string.Equals(attribute.Name, kCesiumLongitude, StringComparison.Ordinal) && + TryToDouble(attribute.Value, out double lon)) + { + longitude = lon; + haveLongitude = true; + } + else if (string.Equals(attribute.Name, kCesiumHeight, StringComparison.Ordinal) && + TryToDouble(attribute.Value, out double hgt)) + { + height = hgt; + haveHeight = true; + } + } + + // Fail closed: latitude, longitude and ellipsoidal height are all required to place + // the prim. A missing height is *not* defaulted to 0 m โ€” for a geodetic height above + // the ellipsoid that silently publishes a wrong altitude โ€” so like the two horizontal + // components an absent height yields no portable anchor at all. + return haveLatitude && haveLongitude && haveHeight; + } + + private static bool TryToDouble(object? value, out double result) + { + switch (value) + { + case double d: + result = d; + return true; + case float f: + result = f; + return true; + case long l: + result = l; + return true; + case int i: + result = i; + return true; + case string s when double.TryParse( + s, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed): + result = parsed; + return true; + default: + result = 0.0; + return false; + } + } + + private static void Attach( + ISystemContext context, NodeState parent, BaseInstanceState node, NodeId referenceTypeId) + { + node.ReferenceTypeId = referenceTypeId; + parent.AddChild(node); + node.NodeId = context.NodeIdFactory.New(context, node); + } + + private static void EnsureNodeId(ISystemContext context, NodeState node) + { + if (node.NodeId.IsNull) + { + node.NodeId = context.NodeIdFactory.New(context, node); + } + } + } + + /// + /// Accumulates the per-node state a materialization must carry to the exporter for a + /// lossless .usda round trip (ยง7.2, ยง7.4): the time samples of every sampled + /// attribute (with the ยง9 epoch / TimeCodesPerSecond mapping context) and the + /// authored connection paths of every connected attribute. Both are keyed by node identity + /// because materialized attributes deliberately share the model's placeholder NodeId, so + /// only object identity distinguishes them. + /// + /// + /// Connection paths are recorded here โ€” rather than reconstructed from the forward + /// UsdConnection edges on export โ€” for two reasons the reference graph cannot serve + /// (ยง5.4). First, reference enumeration order is not the authored order, so replaying the + /// recorded list is what keeps the exported connection sequence deterministic and equal to + /// the authored order (M-2). Second, a .connect whose target lies outside the + /// materialized subtree has no browsable edge at all, so only the recorded path lets it + /// survive materializeโ†’export (M-5) โ€” the same full-fidelity guarantee a relationship gets + /// from its TargetPaths property. The attribute model carries no ConnectionPaths + /// member, so this side channel stands in for one until the model gains it. + /// + internal sealed class UsdMaterializationRecorder + { + private readonly Dictionary> _samplesByNode = + new Dictionary>(); + private readonly Dictionary> _connectionsByNode = + new Dictionary>(); + + public UsdMaterializationRecorder(DateTime? epochUtc, double? timeCodesPerSecond) + { + EpochUtc = epochUtc; + TimeCodesPerSecond = timeCodesPerSecond; + } + + public DateTime? EpochUtc { get; } + + public double? TimeCodesPerSecond { get; } + + public IReadOnlyDictionary> SamplesByNode => + _samplesByNode; + + public IReadOnlyDictionary> ConnectionsByNode => + _connectionsByNode; + + public void Record(UsdAttributeState node, UsdAttribute attribute) + { + var samples = new List(attribute.TimeSamples.Count); + foreach (KeyValuePair sample in attribute.TimeSamples) + { + samples.Add(new UsdTimeSample(sample.Key, sample.Value)); + } + _samplesByNode[node] = samples; + } + + public void RecordConnections(UsdAttributeState node, IList connections) + { + // Snapshot the authored order verbatim, including any target outside the + // materialized subtree; the exporter replays this list to reproduce the exact + // authored connection sequence (ยง5.4). + var paths = new List(connections.Count); + foreach (string path in connections) + { + paths.Add(path); + } + _connectionsByNode[node] = paths; + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.cs b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.cs new file mode 100644 index 0000000000..39d915c4aa --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene.Server/UsdSceneMaterializer.cs @@ -0,0 +1,629 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Server +{ + /// + /// Options controlling how much of a scene is materialized. Each switch corresponds to a + /// conformance unit of draft OPC UA โ€” OpenUSD Scene Materialization ยง12, so a Server + /// materializes only what it needs; Scene Structure is the always-on baseline. + /// + public sealed class UsdMaterializationOptions + { + /// + /// Materialize Composition/ arcs and VariantSets/ โ€” the + /// Composition Provenance CU (ยง5.6, ยง7.4). + /// + public bool MaterializeComposition { get; set; } = true; + + /// + /// Materialize applied API schemas as AddIns under AppliedSchemas/ โ€” the + /// Applied Schemas CU (ยง5.6, ยง8.2). + /// + public bool MaterializeAppliedSchemas { get; set; } = true; + + /// + /// Materialize non-well-known prim metadata under Metadata/ (ยง6.3). + /// + public bool MaterializeMetadata { get; set; } = true; + + /// + /// When a vendor georeference or globe-anchor schema is recognised, additionally + /// author the portable UsdGeoreferenceApiType / UsdGlobeAnchorApiType + /// carrying the same values, so a generic client obtains the anchor from one + /// well-known type โ€” the Georeferencing CU (ยง5.8). + /// + public bool DualAuthorPortableGeoreference { get; set; } = true; + + /// + /// Mark attributes flagged as live in the scene document as historizing, so a Server + /// that retains a value timeline exposes it through HistoricalAccess (ยง9 Mode A). + /// + public bool HistorizeLiveAttributes { get; set; } = true; + + /// + /// The wall-clock UTC instant that USD time code 0 maps to, used to relate stage + /// timeline ordinates to OPC UA timestamps when a Server serves time samples through + /// HistoricalAccess (ยง9). USD time codes are stage-timeline ordinates, not wall clock; + /// they relate to UTC only through an explicit epoch together with the stage's + /// TimeCodesPerSecond. This is deliberately left null by default โ€” no epoch + /// is invented. When it is null the sample timeline is Server-defined and + /// returns null. + /// + public DateTime? TimeCodeEpochUtc { get; set; } + } + + /// + /// One time sample of a USD attribute: a value at a stage-timeline time code (ยง7.1, ยง9). + /// The time code is a stage-timeline ordinate (it may be negative or fractional); it relates + /// to wall-clock time only through an explicit epoch and the stage's TimeCodesPerSecond + /// (see ). + /// + public readonly struct UsdTimeSample : IEquatable + { + /// + /// Creates a time sample. + /// + /// The stage-timeline time code. + /// The sampled value, in the same object shapes the reader produces. + public UsdTimeSample(double timeCode, object? value) + { + TimeCode = timeCode; + Value = value; + } + + /// + /// The stage-timeline time code (may be negative or fractional). + /// + public double TimeCode { get; } + + /// + /// The sampled value. + /// + public object? Value { get; } + + /// + public bool Equals(UsdTimeSample other) + { + return TimeCode.Equals(other.TimeCode) && Equals(Value, other.Value); + } + + /// + public override bool Equals(object? obj) + { + return obj is UsdTimeSample other && Equals(other); + } + + /// + public override int GetHashCode() + { + return TimeCode.GetHashCode(); + } + + /// + /// Equality operator. + /// + public static bool operator ==(UsdTimeSample left, UsdTimeSample right) + { + return left.Equals(right); + } + + /// + /// Inequality operator. + /// + public static bool operator !=(UsdTimeSample left, UsdTimeSample right) + { + return !left.Equals(right); + } + } + + /// + /// The HistoricalAccess surface of one materialized sampled attribute: the node, its composed + /// scene path, the ordered time samples and the mapping context needed to relate USD time codes + /// to wall-clock time (ยง7.1 step 3, ยง9). + /// + /// + /// The materializer sets Historizing and the HistoryRead access bit on the node and + /// records the samples here, but this library does not itself answer HistoryRead โ€” in + /// this stack history is served at the NodeManager layer, and no per-variable read-history + /// callback exists on to hook. To turn these samples into a + /// working HistoricalAccess surface a hosting Server must still: (1) advertise the + /// HistoricalAccess server capability and, if desired, add the HA configuration Object under + /// each sampled node; (2) load these into a historian store keyed by the + /// node, mapping each sample onto a DataValue whose SourceTimestamp comes from + /// (or a Server-defined timeline when no epoch was declared, ยง9); and + /// (3) answer the HistoryRead RAW/PROCESSED service for those nodes from + /// that store โ€” for example by overriding HistoryRead on the owning NodeManager or + /// registering a historian provider for them. is provided so step (2) + /// maps every sample onto a UTC timestamp consistently. + /// + public sealed class UsdHistoricalAccess + { + internal UsdHistoricalAccess( + UsdAttributeState node, + string attributePath, + IReadOnlyList samples, + double? timeCodesPerSecond, + DateTime? epochUtc) + { + Node = node; + AttributePath = attributePath; + Samples = samples; + TimeCodesPerSecond = timeCodesPerSecond; + EpochUtc = epochUtc; + } + + /// + /// The materialized attribute node whose history these samples are. + /// + public UsdAttributeState Node { get; } + + /// + /// The attribute's composed-scene path (<primPath>.<attributeName>). + /// + public string AttributePath { get; } + + /// + /// The time samples, ordered by ascending time code. + /// + public IReadOnlyList Samples { get; } + + /// + /// The stage's TimeCodesPerSecond, or null when the stage declares none. Only + /// meaningful together with . + /// + public double? TimeCodesPerSecond { get; } + + /// + /// The declared wall-clock UTC instant of time code 0, or null when no epoch + /// was declared. When null the timeline is Server-defined (ยง9). + /// + public DateTime? EpochUtc { get; } + + /// + /// Maps a USD time code to wall-clock UTC using the declared epoch and + /// . Returns null when no epoch is declared or + /// is absent or non-positive โ€” the mapping is then + /// Server-defined and this method never invents one (fail closed, ยง9). + /// + /// The stage-timeline time code to map. + /// The UTC instant, or null when no epoch mapping is defined. + public DateTime? ResolveUtc(double timeCode) + { + if (EpochUtc is not DateTime epoch || + TimeCodesPerSecond is not double tcps || + tcps <= 0.0 || + double.IsNaN(timeCode) || + double.IsInfinity(timeCode)) + { + return null; + } + return epoch.AddSeconds(timeCode / tcps); + } + } + + /// + /// The address-space nodes produced by a materialization, indexed so a caller can bind + /// live data to them or resolve a Part 1 binding target (ยง10). + /// + public sealed class UsdMaterializationResult + { + internal UsdMaterializationResult( + UsdStageState stage, + Dictionary primsByPath, + Dictionary attributesByPath, + Dictionary historicalAccessByPath, + IReadOnlyDictionary> samplesByNode, + IReadOnlyDictionary> connectionsByNode) + { + Stage = stage; + PrimsByPath = primsByPath; + AttributesByPath = attributesByPath; + HistoricalAccessByPath = historicalAccessByPath; + SamplesByNode = samplesByNode; + ConnectionsByNode = connectionsByNode; + } + + /// + /// The materialized stage Object. + /// + public UsdStageState Stage { get; } + + /// + /// Every materialized prim, keyed by its absolute SdfPath (for example + /// /Plant/Pumps/P101). + /// + public IReadOnlyDictionary PrimsByPath { get; } + + /// + /// Every materialized attribute, keyed by <primPath>.<attributeName> + /// (for example /Plant/Pumps/P101/Pump/Impeller.xformOp:rotateZ). This is the + /// index a Part 1 binding resolves its target against (ยง10). + /// + public IReadOnlyDictionary AttributesByPath { get; } + + /// + /// Every composed-scene attribute that carries time samples, keyed by the same + /// <primPath>.<attributeName> path as + /// (ยง7.1 step 3). Each entry exposes the sampled node โ€” materialized with its authored + /// default in Value and Historizing set โ€” together + /// with the ordered samples and the ยง9 epoch / TimeCodesPerSecond mapping context. + /// A hosting Server binds these to its historian to answer HistoryRead; this library + /// authors the history metadata but does not itself serve the history (see the type + /// documentation on ). Variant-branch attributes are + /// authoring provenance and are deliberately absent here. + /// + public IReadOnlyDictionary HistoricalAccessByPath { get; } + + /// + /// Samples keyed by attribute node identity, covering every sampled attribute in the whole + /// materialization โ€” including those inside variant branches โ€” so the exporter can recover + /// each node's .timeSamples for a lossless .usda round trip (ยง7.2). Keyed by + /// node reference because materialized attributes deliberately share the model's placeholder + /// NodeId, so only object identity distinguishes them. + /// + internal IReadOnlyDictionary> SamplesByNode { get; } + + /// + /// The authored connection paths of every connected attribute in the whole materialization + /// โ€” including those inside variant branches โ€” in authored order, keyed by attribute node + /// identity. The exporter replays these so the exported connection sequence is deterministic + /// and equal to the authored order, and so a .connect whose target lies outside the + /// materialized subtree (and therefore has no browsable UsdConnection edge) still + /// survives materializeโ†’export (ยง5.4, ยง7.4). This is the connection counterpart of a + /// relationship's TargetPaths property; it lives on the result rather than on the + /// node because the attribute model carries no ConnectionPaths member. Keyed by node + /// reference for the same placeholder-NodeId reason as . + /// + internal IReadOnlyDictionary> ConnectionsByNode { get; } + } + + /// + /// Materializes a composed USD stage into an OPC UA address space per draft + /// OPC UA โ€” OpenUSD Scene Materialization ยง7.1, so that the prim hierarchy *is* the node + /// hierarchy and browsing the server is browsing the scene. + /// + public static partial class UsdSceneMaterializer + { + /// + /// Materializes a stage as a UsdStageType Object under . + /// + /// The server system context. + /// The node the stage is attached to โ€” typically the Stages + /// discovery folder. + /// The composed scene to materialize. + /// The OpenUSD Scene companion namespace index. + /// Which conformance units to materialize; defaults to all. + /// The materialized stage and its prim/attribute indexes. + public static UsdMaterializationResult MaterializeUsdStage( + this ISystemContext context, + NodeState parent, + UsdStage stage, + ushort ns, + UsdMaterializationOptions? options = null) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (parent is null) + { + throw new ArgumentNullException(nameof(parent)); + } + if (stage is null) + { + throw new ArgumentNullException(nameof(stage)); + } + options ??= new UsdMaterializationOptions(); + + UsdStageState stageNode = context.CreateInstanceOfUsdStageType( + parent, new QualifiedName(stage.StageName, ns)); + // The instance factory leaves ReferenceTypeId = Null; HasComponent makes the + // stage browsable from the folder it was attached to. + stageNode.ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HasComponent; + parent.AddChild(stageNode); + stageNode.NodeId = context.NodeIdFactory.New(context, stageNode); + + ApplyStageMetadata(context, stageNode, stage); + + var prims = new Dictionary(StringComparer.Ordinal); + var attributes = new Dictionary(StringComparer.Ordinal); + var pendingRelationships = new List<(UsdRelationshipState Node, UsdRelationship Source)>(); + var pendingConnections = new List<(UsdAttributeState Node, UsdAttribute Source)>(); + + // ยง9: the epoch is Server-declared (never invented) and the stage carries + // TimeCodesPerSecond; together they let a Server relate the recorded stage-timeline + // samples to wall-clock time. Both may be absent, in which case the timeline is + // Server-defined. + var recorder = new UsdMaterializationRecorder(options.TimeCodeEpochUtc, stage.TimeCodesPerSecond); + + foreach (UsdPrim root in stage.RootPrims) + { + MaterializePrim( + context, stageNode, root, "/" + root.Name, ns, options, + prims, attributes, pendingRelationships, pendingConnections, recorder); + } + + // Second pass: targets and connections can point at any prim in the stage, so + // they are resolved only once every node exists and has a NodeId. + ResolveRelationshipTargets(context, pendingRelationships, prims, attributes); + ResolveConnections(context, pendingConnections, attributes); + + // The composed-scene HistoricalAccess surface is derived from the composed attribute + // index, so only composed attributes are exposed โ€” variant-branch samples stay in the + // node-keyed map for the exporter alone (they are un-composed provenance, ยง7.4). + var historicalAccessByPath = + new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair entry in attributes) + { + if (recorder.SamplesByNode.TryGetValue( + entry.Value, out IReadOnlyList? samples)) + { + historicalAccessByPath[entry.Key] = new UsdHistoricalAccess( + entry.Value, entry.Key, samples, + recorder.TimeCodesPerSecond, recorder.EpochUtc); + } + } + + return new UsdMaterializationResult( + stageNode, prims, attributes, historicalAccessByPath, + recorder.SamplesByNode, recorder.ConnectionsByNode); + } + + private static void ApplyStageMetadata( + ISystemContext context, UsdStageState stageNode, UsdStage stage) + { + if (!string.IsNullOrEmpty(stage.DefaultPrim)) + { + stageNode.CreateOrReplaceDefaultPrim(context, null!).Value = stage.DefaultPrim; + } + if (!string.IsNullOrEmpty(stage.UpAxis)) + { + stageNode.CreateOrReplaceUpAxis(context, null!).Value = stage.UpAxis; + } + stageNode.CreateOrReplaceMetersPerUnit(context, null!).Value = stage.MetersPerUnit; + if (stage.KilogramsPerUnit.HasValue) + { + stageNode.CreateOrReplaceKilogramsPerUnit(context, null!).Value = + stage.KilogramsPerUnit.Value; + } + if (stage.TimeCodesPerSecond.HasValue) + { + stageNode.CreateOrReplaceTimeCodesPerSecond(context, null!).Value = + stage.TimeCodesPerSecond.Value; + } + if (stage.StartTimeCode.HasValue) + { + stageNode.CreateOrReplaceStartTimeCode(context, null!).Value = + stage.StartTimeCode.Value; + } + if (stage.EndTimeCode.HasValue) + { + stageNode.CreateOrReplaceEndTimeCode(context, null!).Value = + stage.EndTimeCode.Value; + } + if (!string.IsNullOrEmpty(stage.RootLayerIdentifier)) + { + stageNode.CreateOrReplaceRootLayerIdentifier(context, null!).Value = + stage.RootLayerIdentifier; + } + if (!string.IsNullOrEmpty(stage.Documentation)) + { + stageNode.CreateOrReplaceDocumentation(context, null!).Value = stage.Documentation; + } + } + + private static void MaterializePrim( + ISystemContext context, + NodeState parent, + UsdPrim prim, + string path, + ushort ns, + UsdMaterializationOptions options, + Dictionary prims, + Dictionary attributes, + List<(UsdRelationshipState, UsdRelationship)> pendingRelationships, + List<(UsdAttributeState, UsdAttribute)> pendingConnections, + UsdMaterializationRecorder recorder) + { + var browseName = new QualifiedName(prim.Name, ns); + + // A prim of a known typed schema is instantiated as that generated ObjectType + // subclass, so the node *is* the type it declares and carries the subtype's + // members (ยง5.3). An unknown schema returns null here and degrades to a concrete + // UsdPrimType via AddxUsdPrim_, still keeping its TypeName token (ยง8.4). + UsdPrimState? primNode = CreateTypedPrimInstance(context, parent, prim.TypeName, browseName); + primNode ??= parent switch + { + UsdStageState stageParent => stageParent.AddxUsdPrim_(context, browseName), + UsdPrimState primParent => primParent.AddxUsdPrim_(context, browseName), + _ => throw new ArgumentException( + "A prim can only be materialized under a stage or another prim.", + nameof(parent)) + }; + + PopulatePrimNode( + context, primNode, prim, path, ns, options, + prims, attributes, pendingRelationships, pendingConnections, recorder); + } + + /// + /// Populates an already-created prim node with its properties, attributes, relationships, + /// composition/schema/metadata folders and child prims (everything after the node itself + /// exists). Split out from so a variant branch โ€” whose body is + /// prim-shaped โ€” is materialized with exactly the same fidelity as a top-level prim (ยง5.6). + /// + private static void PopulatePrimNode( + ISystemContext context, + UsdPrimState primNode, + UsdPrim prim, + string path, + ushort ns, + UsdMaterializationOptions options, + Dictionary prims, + Dictionary attributes, + List<(UsdRelationshipState, UsdRelationship)> pendingRelationships, + List<(UsdAttributeState, UsdAttribute)> pendingConnections, + UsdMaterializationRecorder recorder) + { + primNode.CreateOrReplaceSpecifier(context, null!).Value = prim.Specifier; + if (!string.IsNullOrEmpty(prim.TypeName)) + { + primNode.CreateOrReplaceTypeName(context, null!).Value = prim.TypeName; + } + primNode.CreateOrReplaceKind(context, null!).Value = prim.Kind; + primNode.CreateOrReplaceActive(context, null!).Value = prim.Active; + if (prim.Instanceable) + { + primNode.CreateOrReplaceInstanceable(context, null!).Value = true; + } + if (!string.IsNullOrEmpty(prim.Documentation)) + { + primNode.CreateOrReplaceDocumentation(context, null!).Value = prim.Documentation; + } + + prims[path] = primNode; + + foreach (UsdAttribute attribute in prim.Attributes) + { + UsdAttributeState attributeNode = MaterializeAttribute( + context, primNode, attribute, ns, options); + string attributePath = path + "." + attribute.Name; + attributes[attributePath] = attributeNode; + if (attribute.Connections.Count > 0) + { + pendingConnections.Add((attributeNode, attribute)); + // Record the authored connection order (including any target outside the + // materialized subtree) so the exporter reproduces the exact authored + // sequence and never drops an unresolvable .connect (M-2, M-5; ยง5.4, ยง7.4). + recorder.RecordConnections(attributeNode, attribute.Connections); + } + if (attribute.TimeSamples.Count > 0) + { + recorder.Record(attributeNode, attribute); + } + } + + foreach (UsdRelationship relationship in prim.Relationships) + { + UsdRelationshipState relationshipNode = MaterializeRelationship( + context, primNode, relationship, ns); + pendingRelationships.Add((relationshipNode, relationship)); + } + + // Applied API schemas materialize as AddIns; additionally, a *typed* Cesium + // georeference/globe-anchor prim (which carries no applied schema) still needs the + // AppliedSchemas/ folder so the portable georeference can be dual-authored (ยง5.8). + if (options.MaterializeAppliedSchemas && + (prim.ApiSchemas.Count > 0 || + (options.DualAuthorPortableGeoreference && IsGeoreferenceTypedPrim(prim)))) + { + MaterializeAppliedSchemas(context, primNode, prim, ns, options); + } + if (options.MaterializeComposition) + { + if (prim.Composition.Count > 0) + { + MaterializeComposition(context, primNode, prim, ns); + } + if (prim.VariantSets.Count > 0) + { + MaterializeVariantSets(context, primNode, prim, ns, options, recorder); + } + } + if (options.MaterializeMetadata && prim.Metadata.Count > 0) + { + MaterializeMetadata(context, primNode, prim, ns); + } + + foreach (UsdPrim child in prim.Children) + { + MaterializePrim( + context, primNode, child, path + "/" + child.Name, ns, options, + prims, attributes, pendingRelationships, pendingConnections, recorder); + } + } + + /// + /// Instantiates a prim as the generated State subclass for a known typed schema (ยง5.3), + /// attached under exactly as AddxUsdPrim_ or the stage + /// attach idiom would attach a plain prim (HasComponent + a factory NodeId, so it + /// is browsable and uniquely identified identically). Returns null for an unknown + /// or untyped schema so the caller applies the ยง8.4 fallback โ€” a concrete + /// UsdPrimType that still keeps its TypeName token. + /// + /// + /// The subtype's declared members are all Optional and are deliberately *not* pre-created + /// here. ยง7.1 step 3 makes the generic <UsdAttribute> pass authoritative for + /// every attribute value, so authoring an empty typed member (e.g. an empty + /// XformOpOrder) would both duplicate that state and publish a plausible-but-wrong + /// placeholder โ€” the opposite of failing closed. Instantiating the subclass gives the node + /// its correct type identity (and the typed accessors) while the attribute pass remains the + /// single source of values. + /// + private static UsdPrimState? CreateTypedPrimInstance( + ISystemContext context, NodeState parent, string typeName, QualifiedName browseName) + { + if (string.IsNullOrEmpty(typeName)) + { + return null; + } + + UsdPrimState? node = typeName switch + { + "Xform" => context.CreateInstanceOfUsdGeomXformType(parent, browseName), + "Scope" => context.CreateInstanceOfUsdGeomScopeType(parent, browseName), + "Mesh" => context.CreateInstanceOfUsdGeomMeshType(parent, browseName), + "Cylinder" => context.CreateInstanceOfUsdGeomCylinderType(parent, browseName), + "Sphere" => context.CreateInstanceOfUsdGeomSphereType(parent, browseName), + "Cube" => context.CreateInstanceOfUsdGeomCubeType(parent, browseName), + "Cone" => context.CreateInstanceOfUsdGeomConeType(parent, browseName), + "Capsule" => context.CreateInstanceOfUsdGeomCapsuleType(parent, browseName), + "Material" => context.CreateInstanceOfUsdShadeMaterialType(parent, browseName), + "Shader" => context.CreateInstanceOfUsdShadeShaderType(parent, browseName), + _ => null + }; + if (node is null) + { + return null; + } + + // The instance factory leaves ReferenceTypeId = Null and seeds a type NodeId; + // Attach makes it a HasComponent child with a fresh factory NodeId, matching how + // AddxUsdPrim_ and MaterializeUsdStage attach every other node (ยง7.1). + Attach(context, parent, node, Opc.Ua.ReferenceTypeIds.HasComponent); + return node; + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdSceneSignature.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdSceneSignature.cs new file mode 100644 index 0000000000..b50ba90540 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdSceneSignature.cs @@ -0,0 +1,296 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Globalization; +using System.Linq; +using System.Text; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Conversion +{ + /// + /// Computes the normative "composed-scene lossless" signature of a , the + /// equivalence oracle of draft OPC UA โ€” OpenUSD Scene Materialization ยง7.4. + /// + /// + /// + /// This is the C# port of the specification reference converter's scene_signature (see + /// metaverse-specs/extras/openusd-scene/tools/scene_common.py). Two stages are ยง7.4 + /// round-trip equivalent when, and only when, their signatures are equal. + /// + /// + /// The signature captures, for every prim (ordered by path): its path, type name, kind, + /// specifier and documentation; the sorted set of attributes (name, type name, normalized value, + /// variability, custom flag and connections); the sorted set of relationships (name and targets); + /// the ordered list of composition arcs (kind, asset path, prim path, list position); and the + /// ordered list of variant sets (name and selection). It deliberately excludes non-composed state + /// such as prim activation, instanceable flags, applied API schemas, live-value flags, prim + /// metadata and all stage-level metadata, matching the reference oracle. + /// + /// + /// Numeric values are normalized to and tuples and arrays are normalized to a + /// single ordered-list form, so an int-versus-float or tuple-versus-array difference + /// introduced by a lossless write does not perturb the signature. + /// + /// + public static class UsdSceneSignature + { + private const char FieldSeparator = '\u001f'; + private const char UnitSeparator = '\u001e'; + + /// + /// Computes the full signature of a stage as a single string. Two stages with equal signature + /// strings are ยง7.4 round-trip equivalent. + /// + /// The stage to sign. + /// The signature string. + public static string Compute(UsdStage stage) + { + return string.Join("\n", ComputeLines(stage)); + } + + /// + /// Computes the signature of a stage as one canonical line per prim, ordered by prim path. + /// This is the form used to produce a readable diff on a round-trip failure. + /// + /// The stage to sign. + /// The per-prim signature lines. + public static IReadOnlyList ComputeLines(UsdStage stage) + { + if (stage == null) + { + throw new ArgumentNullException(nameof(stage)); + } + + var lines = new List(); + foreach (UsdPrim prim in stage.AllPrims().OrderBy(p => p.Path, StringComparer.Ordinal)) + { + lines.Add(SignPrim(prim)); + } + return lines; + } + + /// + /// Finds the first prim-level difference between two stage signatures, if any. + /// + /// The expected (reference) stage. + /// The actual (for example re-parsed) stage. + /// A human-readable description of the first difference, or null when the two + /// stages have identical signatures. + public static string? FirstDifference(UsdStage expected, UsdStage actual) + { + IReadOnlyList a = ComputeLines(expected); + IReadOnlyList b = ComputeLines(actual); + int count = Math.Max(a.Count, b.Count); + for (int i = 0; i < count; i++) + { + string ea = i < a.Count ? a[i] : ""; + string eb = i < b.Count ? b[i] : ""; + if (!string.Equals(ea, eb, StringComparison.Ordinal)) + { + return "signature differs at prim #" + i.ToString(CultureInfo.InvariantCulture) + + ":\n expected: " + Readable(ea) + + "\n actual: " + Readable(eb); + } + } + return null; + } + + private static string Readable(string line) + { + return line.Replace(FieldSeparator, '|').Replace(UnitSeparator, '~'); + } + + private static string SignPrim(UsdPrim prim) + { + var attrs = prim.Attributes.Select(SignAttribute).ToList(); + attrs.Sort(StringComparer.Ordinal); + + var rels = prim.Relationships.Select(SignRelationship).ToList(); + rels.Sort(StringComparer.Ordinal); + + var arcs = prim.Composition.Select(SignArc).ToList(); + var variants = prim.VariantSets.Select(SignVariant).ToList(); + + var sb = new StringBuilder(); + sb.Append(prim.Path); + sb.Append(FieldSeparator).Append(prim.TypeName); + sb.Append(FieldSeparator).Append(prim.Kind.ToString()); + sb.Append(FieldSeparator).Append(prim.Specifier.ToString()); + sb.Append(FieldSeparator).Append(prim.Documentation); + sb.Append(FieldSeparator).Append("A(").Append(string.Join(UnitSeparator.ToString(), attrs)).Append(')'); + sb.Append(FieldSeparator).Append("R(").Append(string.Join(UnitSeparator.ToString(), rels)).Append(')'); + sb.Append(FieldSeparator).Append("C(").Append(string.Join(UnitSeparator.ToString(), arcs)).Append(')'); + sb.Append(FieldSeparator).Append("V(").Append(string.Join(UnitSeparator.ToString(), variants)).Append(')'); + return sb.ToString(); + } + + private static string SignAttribute(UsdAttribute attr) + { + var sb = new StringBuilder(); + sb.Append(attr.Name); + sb.Append('\u0001').Append(attr.TypeName); + sb.Append('\u0001').Append(NormalizeValue(attr.Value)); + sb.Append('\u0001').Append(attr.Variability.ToString()); + sb.Append('\u0001').Append(attr.Custom ? "1" : "0"); + sb.Append('\u0001').Append('[').Append(string.Join(",", attr.Connections)).Append(']'); + + // Time samples are authored attribute data the ยง7.4 round trip must preserve, so they + // are part of the signature โ€” but only appended when present, leaving the signature of + // a sample-less attribute byte-for-byte unchanged. The reference oracle does not model + // time samples, so this deliberately makes the C# signature strictly stronger. + if (attr.TimeSamples.Count > 0) + { + sb.Append('\u0001').Append("TS("); + bool first = true; + foreach (KeyValuePair sample in attr.TimeSamples) + { + if (!first) + { + sb.Append('\u0002'); + } + first = false; + sb.Append(FormatNumber(sample.Key)).Append('=').Append(NormalizeValue(sample.Value)); + } + sb.Append(')'); + } + return sb.ToString(); + } + + private static string SignRelationship(UsdRelationship rel) + { + return rel.Name + "\u0001[" + string.Join(",", rel.Targets) + "]"; + } + + private static string SignArc(UsdCompositionArc arc) + { + return arc.ArcKind.ToString() + '\u0001' + arc.AssetPath + '\u0001' + arc.PrimPath + + '\u0001' + arc.ListPosition.ToString(); + } + + /// + /// Signs a variant set from its name and resolved selection only. + /// + /// + /// The ยง7.4 contract requires two composed-scene properties: the scene must be + /// "variant-selection-equivalent" and the composition arc list must be preserved. Both are + /// properties of the composed result โ€” the selection determines which branch was + /// composed in, and that selection is what must round-trip. The non-selected + /// <Variant> branches captured in are + /// un-composed authoring provenance (ยง5.6, Composition Provenance CU); they are deliberately + /// out of scope for the composed-scene equivalence, matching the reference oracle's + /// scene_signature, which signs only (set_name, selection). Excluding them also + /// keeps every signature that has no variant branches byte-for-byte unchanged. + /// + private static string SignVariant(UsdVariantSet variant) + { + return variant.SetName + '\u0001' + variant.Selection; + } + + /// + /// Normalizes an attribute value to a canonical JSON-like string (port of _norm plus a + /// deterministic serialization): numbers become doubles, tuples and arrays become a single + /// ordered-list form, so lossless int/float and tuple/array differences collapse. + /// + private static string NormalizeValue(object? value) + { + switch (value) + { + case null: + return "null"; + case bool b: + return b ? "true" : "false"; + case string s: + return Quote(s); + case double d: + return FormatNumber(d); + case float f: + return FormatNumber(f); + case long l: + return FormatNumber(l); + case int i: + return FormatNumber(i); + } + + if (value is object?[] tuple) + { + return "[" + string.Join(",", tuple.Select(NormalizeValue)) + "]"; + } + if (value is System.Collections.IEnumerable enumerable) + { + var items = new List(); + foreach (object? item in enumerable) + { + items.Add(NormalizeValue(item)); + } + return "[" + string.Join(",", items) + "]"; + } + return Quote(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); + } + + private static string FormatNumber(double d) + { + return d.ToString("R", CultureInfo.InvariantCulture); + } + + private static string Quote(string s) + { + var sb = new StringBuilder(s.Length + 2); + sb.Append('"'); + foreach (char c in s) + { + switch (c) + { + case '"': + sb.Append("\\\""); + break; + case '\\': + sb.Append("\\\\"); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + sb.Append(c); + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs new file mode 100644 index 0000000000..f98c565c17 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueCoercion.cs @@ -0,0 +1,557 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; +using System.Collections.Generic; +using System.Globalization; + +namespace Opc.Ua.OpenUsdScene.Conversion +{ + /// + /// Converts a parsed USD attribute value into the OPC UA shape + /// required by its ยง6.2 binding, and back again. + /// + /// + /// + /// The input is deliberately loosely typed: any non-string is + /// treated as a sequence, so a reader may represent a USD tuple or array as an + /// object[], a List<object> or any other sequence without the + /// materializer having to know which. + /// + /// + /// A is produced through the statically typed + /// Variant.From overloads rather than the reflection-based boxing constructor, so + /// the library stays trim- and AOT-compatible. + /// + /// + /// Conversion fails closed: a source that cannot be represented in the declared type + /// leaves the attribute unresolved rather than being coerced to a plausible but wrong + /// value. + /// + /// + public static class UsdValueCoercion + { + /// + /// Coerces a parsed USD value into the Variant shape the mapping calls for. + /// + /// The parsed USD value. + /// The ยง6.2 binding chosen for the attribute's type. + /// The fixed component count of the USD type, or 0 when + /// the type is not a fixed-size math type. + /// The coerced value. + /// true when the value could be represented. + public static bool TryCoerce( + object? value, UsdValueTypeMapping mapping, uint componentCount, out Variant result) + { + result = default; + if (mapping == null) + { + throw new ArgumentNullException(nameof(mapping)); + } + if (value == null) + { + return false; + } + if (mapping.IsOpaque) + { + // An unrecognised value type is carried opaquely rather than dropped; the + // attribute's UsdTypeName still records the exact spelling (ยง8.4). The value is + // rendered to its authored .usda text through the writer's own emitter, so a later + // export reproduces it faithfully instead of publishing a CLR type name for a + // structured value (for example a "(0.1, 0.2, 0.3)" tuple). A value the writer + // cannot render is left unresolved rather than coerced to a plausible-but-wrong + // string (fail closed). + if (UsdaWriter.TryRenderOpaqueValue(value, out string opaque)) + { + result = Variant.From(opaque); + return true; + } + return false; + } + + int width = (int)componentCount; + if (mapping.ValueRank == ValueRanks.Scalar) + { + return TryScalar(value, mapping.ElementType, out result); + } + if (mapping.ValueRank == ValueRanks.OneDimension) + { + IReadOnlyList items; + if (width > 0) + { + // A fixed-size math type is a flat array of components, but USD authors a + // matrix4d as four nested 4-tuples, so flatten to the leaves before the arity + // check (mirrors the reference converter's _flat). Otherwise a matrix4d would + // report four items against a width of sixteen and be dropped. + items = Flatten(value); + if (items.Count != width) + { + // A fixed-size math type authored with the wrong arity cannot be honoured. + return false; + } + } + else + { + items = AsSequence(value); + } + return TryArray(items, mapping.ElementType, out result); + } + if (mapping.ValueRank == ValueRanks.TwoDimensions && width > 0) + { + IReadOnlyList rows = AsSequence(value); + var flat = new List(rows.Count * width); + foreach (object? row in rows) + { + // Flatten each element to its leaves so an array of matrix4d (each authored as + // four nested 4-tuples) is honoured. The outer array stays grouped one row per + // element โ€” only the element shape is flattened, so a genuinely nested + // array-of-tuples such as color3f[] keeps its per-tuple grouping. + List cells = Flatten(row); + if (cells.Count != width) + { + return false; + } + foreach (object? cell in cells) + { + flat.Add(cell); + } + } + return TryMatrix(flat, mapping.ElementType, rows.Count, width, out result); + } + return false; + } + + /// + /// Reads a materialized value back into the plain shape used by the scene document + /// model, so an export reproduces the authored form (ยง7.2). + /// + /// The value read from the materialized Variable. + /// The USD-shaped value. + public static object? Decoerce(object? value) + { + if (value is string || value == null) + { + return value; + } + if (value is Array array) + { + if (array.Rank == 2) + { + // An array-of-tuples type comes back as a rectangular matrix; regroup it + // into the per-tuple rows the scene document model uses. + int rows = array.GetLength(0); + int width = array.GetLength(1); + var grouped = new object?[rows]; + for (int r = 0; r < rows; r++) + { + var cells = new object?[width]; + for (int c = 0; c < width; c++) + { + cells[c] = array.GetValue(r, c); + } + grouped[r] = cells; + } + return grouped; + } + var items = new object?[array.Length]; + array.CopyTo(items, 0); + return items; + } + return value; + } + + private static bool TryScalar(object value, BuiltInType elementType, out Variant result) + { + result = default; + switch (elementType) + { + case BuiltInType.Boolean: + if (!TryConvert(value, Convert.ToBoolean, out bool b)) + { + return false; + } + result = Variant.From(b); + return true; + case BuiltInType.SByte: + if (!TryConvert(value, Convert.ToSByte, out sbyte sb)) + { + return false; + } + result = Variant.From(sb); + return true; + case BuiltInType.Int32: + if (!TryConvert(value, Convert.ToInt32, out int i)) + { + return false; + } + result = Variant.From(i); + return true; + case BuiltInType.Int64: + if (!TryConvert(value, Convert.ToInt64, out long l)) + { + return false; + } + result = Variant.From(l); + return true; + case BuiltInType.UInt32: + if (!TryConvert(value, Convert.ToUInt32, out uint ui)) + { + return false; + } + result = Variant.From(ui); + return true; + case BuiltInType.UInt64: + if (!TryConvert(value, Convert.ToUInt64, out ulong ul)) + { + return false; + } + result = Variant.From(ul); + return true; + case BuiltInType.Float: + if (!TryConvert(value, Convert.ToSingle, out float f)) + { + return false; + } + result = Variant.From(f); + return true; + case BuiltInType.Double: + if (!TryConvert(value, Convert.ToDouble, out double d)) + { + return false; + } + result = Variant.From(d); + return true; + case BuiltInType.String: + if (!TryStringifyLeaf(value, out string scalarString)) + { + return false; + } + result = Variant.From(scalarString); + return true; + default: + return false; + } + } + + private static bool TryArray( + IReadOnlyList items, BuiltInType elementType, out Variant result) + { + result = default; + switch (elementType) + { + case BuiltInType.Boolean: + if (!TryFill(items, Convert.ToBoolean, out bool[] b)) + { + return false; + } + result = Variant.From((ArrayOf)b); + return true; + case BuiltInType.SByte: + if (!TryFill(items, Convert.ToSByte, out sbyte[] sb)) + { + return false; + } + result = Variant.From((ArrayOf)sb); + return true; + case BuiltInType.Int32: + if (!TryFill(items, Convert.ToInt32, out int[] i)) + { + return false; + } + result = Variant.From((ArrayOf)i); + return true; + case BuiltInType.Int64: + if (!TryFill(items, Convert.ToInt64, out long[] l)) + { + return false; + } + result = Variant.From((ArrayOf)l); + return true; + case BuiltInType.UInt32: + if (!TryFill(items, Convert.ToUInt32, out uint[] ui)) + { + return false; + } + result = Variant.From((ArrayOf)ui); + return true; + case BuiltInType.UInt64: + if (!TryFill(items, Convert.ToUInt64, out ulong[] ul)) + { + return false; + } + result = Variant.From((ArrayOf)ul); + return true; + case BuiltInType.Float: + if (!TryFill(items, Convert.ToSingle, out float[] f)) + { + return false; + } + result = Variant.From((ArrayOf)f); + return true; + case BuiltInType.Double: + if (!TryFill(items, Convert.ToDouble, out double[] d)) + { + return false; + } + result = Variant.From((ArrayOf)d); + return true; + case BuiltInType.String: + var strings = new string[items.Count]; + for (int n = 0; n < items.Count; n++) + { + if (!TryStringifyLeaf(items[n], out string element)) + { + return false; + } + strings[n] = element; + } + result = Variant.From((ArrayOf)strings); + return true; + default: + return false; + } + } + + private static bool TryMatrix( + List flat, + BuiltInType elementType, + int rows, + int width, + out Variant result) + { + result = default; + switch (elementType) + { + case BuiltInType.Boolean: + if (!TryFill(flat, Convert.ToBoolean, out bool[] b)) + { + return false; + } + result = Variant.From((MatrixOf)Reshape(b, rows, width)); + return true; + case BuiltInType.SByte: + if (!TryFill(flat, Convert.ToSByte, out sbyte[] sb)) + { + return false; + } + result = Variant.From((MatrixOf)Reshape(sb, rows, width)); + return true; + case BuiltInType.Int32: + if (!TryFill(flat, Convert.ToInt32, out int[] i)) + { + return false; + } + result = Variant.From((MatrixOf)Reshape(i, rows, width)); + return true; + case BuiltInType.Int64: + if (!TryFill(flat, Convert.ToInt64, out long[] l)) + { + return false; + } + result = Variant.From((MatrixOf)Reshape(l, rows, width)); + return true; + case BuiltInType.UInt32: + if (!TryFill(flat, Convert.ToUInt32, out uint[] ui)) + { + return false; + } + result = Variant.From((MatrixOf)Reshape(ui, rows, width)); + return true; + case BuiltInType.UInt64: + if (!TryFill(flat, Convert.ToUInt64, out ulong[] ul)) + { + return false; + } + result = Variant.From((MatrixOf)Reshape(ul, rows, width)); + return true; + case BuiltInType.Float: + if (!TryFill(flat, Convert.ToSingle, out float[] f)) + { + return false; + } + result = Variant.From((MatrixOf)Reshape(f, rows, width)); + return true; + case BuiltInType.Double: + if (!TryFill(flat, Convert.ToDouble, out double[] d)) + { + return false; + } + result = Variant.From((MatrixOf)Reshape(d, rows, width)); + return true; + case BuiltInType.String: + var strings = new string[flat.Count]; + for (int n = 0; n < flat.Count; n++) + { + if (!TryStringifyLeaf(flat[n], out string element)) + { + return false; + } + strings[n] = element; + } + result = Variant.From((MatrixOf)Reshape(strings, rows, width)); + return true; + default: + return false; + } + } + + private static IReadOnlyList AsSequence(object? value) + { + if (value is IReadOnlyList list) + { + return list; + } + if (value is string || value == null) + { + return new[] { value }; + } + if (value is IEnumerable enumerable) + { + var items = new List(); + foreach (object? item in enumerable) + { + items.Add(item); + } + return items; + } + return new object?[] { value }; + } + + /// + /// Recursively flattens nested tuples and arrays to their scalar leaves, mirroring the + /// reference converter's _flat. A string is treated as a leaf, not a character + /// sequence. Used to reconcile a fixed-size math type (matrix4d authored as nested tuples) + /// with its flat component count before the arity check. + /// + private static List Flatten(object? value) + { + var sink = new List(); + FlattenInto(value, sink); + return sink; + } + + private static void FlattenInto(object? value, List sink) + { + if (value is string || value == null) + { + sink.Add(value); + return; + } + if (value is IEnumerable enumerable) + { + foreach (object? item in enumerable) + { + FlattenInto(item, sink); + } + return; + } + sink.Add(value); + } + + private static bool TryConvert( + object value, Func convert, out T result) + { + try + { + result = convert(value, CultureInfo.InvariantCulture); + return true; + } + catch (Exception ex) when (ex is InvalidCastException or FormatException + or OverflowException or ArgumentException) + { + result = default!; + return false; + } + } + + private static bool TryFill( + IReadOnlyList items, Func convert, out T[] result) + { + var target = new T[items.Count]; + for (int i = 0; i < items.Count; i++) + { + if (items[i] == null) + { + target[i] = default!; + continue; + } + if (!TryConvert(items[i]!, convert, out T converted)) + { + result = Array.Empty(); + return false; + } + target[i] = converted; + } + result = target; + return true; + } + + private static T[,] Reshape(T[] flat, int rows, int width) + { + var target = new T[rows, width]; + for (int i = 0; i < flat.Length; i++) + { + target[i / width, i % width] = flat[i]; + } + return target; + } + + /// + /// Renders a single scalar leaf to its faithful invariant-culture string for a + /// string/token/asset attribute. Succeeds only for a value that has a + /// well-defined textual form โ€” null, a string, a bool (as its USD true/ + /// false spelling) or an (numbers, timestamps). It fails + /// closed for a structured value (a tuple/array modelled as object?[] or + /// List<object?>) or any other object, because emitting value.ToString() + /// there would publish a CLR type name such as "System.Object[]" โ€” a plausible but + /// wrong value. The caller returns false so the attribute is left unresolved instead. + /// + private static bool TryStringifyLeaf(object? value, out string result) + { + switch (value) + { + case null: + result = string.Empty; + return true; + case string s: + result = s; + return true; + case bool b: + result = b ? "true" : "false"; + return true; + case IFormattable f: + result = f.ToString(null, CultureInfo.InvariantCulture); + return true; + } + result = string.Empty; + return false; + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueTypeMap.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueTypeMap.cs new file mode 100644 index 0000000000..a01da6405b --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdValueTypeMap.cs @@ -0,0 +1,271 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.OpenUsdScene.Conversion +{ + /// + /// The OPC UA type binding chosen for a USD SdfValueTypeName + /// (draft OPC UA โ€” OpenUSD Scene Materialization ยง6.2). + /// + public sealed class UsdValueTypeMapping + { + internal UsdValueTypeMapping( + NodeId dataTypeId, + int valueRank, + uint[]? arrayDimensions, + BuiltInType elementType, + bool isOpaque) + { + DataTypeId = dataTypeId; + ValueRank = valueRank; + ArrayDimensions = arrayDimensions; + ElementType = elementType; + IsOpaque = isOpaque; + } + + /// + /// The DataType of the materialized Variable. For a role-carrying USD value type this + /// is the semantic subtype of the built-in (for example UsdColor3f : Float), so + /// the role is discoverable from the type system rather than only from the + /// UsdTypeName annotation. + /// + public NodeId DataTypeId { get; } + + /// + /// The ValueRank of the materialized Variable. + /// + public int ValueRank { get; } + + /// + /// The ArrayDimensions of the materialized Variable, or null for a scalar. + /// A fixed-size USD math type pins its extent here (for example 3 for + /// point3f, 16 for matrix4d); a variable-length dimension is 0. + /// + public uint[]? ArrayDimensions { get; } + + /// + /// The built-in type the values are encoded as. Because every role DataType subtypes + /// a built-in, this is what a client that does not recognise the subtype will read. + /// + public BuiltInType ElementType { get; } + + /// + /// Whether the USD type was not recognised and the value is carried opaquely as + /// BaseDataType. The attribute's UsdTypeName still records the exact + /// spelling, so an exporter reproduces it faithfully (ยง8.4). + /// + public bool IsOpaque { get; } + } + + /// + /// Maps a USD SdfValueTypeName onto an OPC UA DataType, ValueRank and + /// ArrayDimensions per the normative table of draft OPC UA โ€” OpenUSD Scene + /// Materialization ยง6.2. + /// + /// + /// The mapping is intentionally many-to-one for generic numeric tuples: a Float[3] + /// unambiguously means float3, while the role variants (color3f, + /// point3f, โ€ฆ) are told apart by their semantic DataType. Reversibility is + /// guaranteed because the exact USD type name is always preserved in the attribute's + /// UsdTypeName property. + /// + public static class UsdValueTypeMap + { + private sealed class Entry + { + public Entry(ExpandedNodeId? semantic, NodeId builtIn, BuiltInType element, uint fixedLength) + { + Semantic = semantic; + BuiltIn = builtIn; + Element = element; + FixedLength = fixedLength; + } + + public ExpandedNodeId? Semantic { get; } + public NodeId BuiltIn { get; } + + public BuiltInType Element { get; } + + /// + /// Zero for a scalar; otherwise the pinned extent of the 1-D array. + /// + public uint FixedLength { get; } + } + + private static readonly Dictionary s_map = + new Dictionary(StringComparer.Ordinal) + { + // Scalars that map straight onto a built-in. + ["bool"] = new Entry(null, Opc.Ua.DataTypeIds.Boolean, BuiltInType.Boolean, 0), + ["uchar"] = new Entry(null, Opc.Ua.DataTypeIds.SByte, BuiltInType.SByte, 0), + ["int"] = new Entry(null, Opc.Ua.DataTypeIds.Int32, BuiltInType.Int32, 0), + ["int64"] = new Entry(null, Opc.Ua.DataTypeIds.Int64, BuiltInType.Int64, 0), + ["uint"] = new Entry(null, Opc.Ua.DataTypeIds.UInt32, BuiltInType.UInt32, 0), + ["uint64"] = new Entry(null, Opc.Ua.DataTypeIds.UInt64, BuiltInType.UInt64, 0), + ["half"] = new Entry(null, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 0), + ["float"] = new Entry(null, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 0), + ["double"] = new Entry(null, Opc.Ua.DataTypeIds.Double, BuiltInType.Double, 0), + ["string"] = new Entry(null, Opc.Ua.DataTypeIds.String, BuiltInType.String, 0), + + // Scalars that carry a USD role, expressed as a subtype of the built-in (ยง5.7). + ["token"] = new Entry( + DataTypeIds.UsdToken, Opc.Ua.DataTypeIds.String, BuiltInType.String, 0), + ["asset"] = new Entry( + DataTypeIds.UsdAssetPath, Opc.Ua.DataTypeIds.String, BuiltInType.String, 0), + ["timecode"] = new Entry( + DataTypeIds.UsdTimeCode, Opc.Ua.DataTypeIds.Double, BuiltInType.Double, 0), + + // Generic fixed-size numeric tuples โ€” the shape alone is the meaning, so these + // stay plain built-in arrays and ArrayDimensions conveys the extent. + ["float2"] = new Entry(null, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 2), + ["float3"] = new Entry(null, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 3), + ["float4"] = new Entry(null, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 4), + ["double2"] = new Entry(null, Opc.Ua.DataTypeIds.Double, BuiltInType.Double, 2), + ["double3"] = new Entry(null, Opc.Ua.DataTypeIds.Double, BuiltInType.Double, 3), + ["double4"] = new Entry(null, Opc.Ua.DataTypeIds.Double, BuiltInType.Double, 4), + ["int2"] = new Entry(null, Opc.Ua.DataTypeIds.Int32, BuiltInType.Int32, 2), + ["int3"] = new Entry(null, Opc.Ua.DataTypeIds.Int32, BuiltInType.Int32, 3), + ["int4"] = new Entry(null, Opc.Ua.DataTypeIds.Int32, BuiltInType.Int32, 4), + + // Role-carrying vectors: same encoding, distinct DataType so a renderer can + // tell a colour from a point without parsing a string. + ["color3f"] = new Entry( + DataTypeIds.UsdColor3f, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 3), + ["normal3f"] = new Entry( + DataTypeIds.UsdNormal3f, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 3), + ["point3f"] = new Entry( + DataTypeIds.UsdPoint3f, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 3), + ["vector3f"] = new Entry( + DataTypeIds.UsdVector3f, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 3), + ["texCoord2f"] = new Entry( + DataTypeIds.UsdTexCoord2f, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 2), + ["quatf"] = new Entry( + DataTypeIds.UsdQuatf, Opc.Ua.DataTypeIds.Float, BuiltInType.Float, 4), + ["quatd"] = new Entry( + DataTypeIds.UsdQuatd, Opc.Ua.DataTypeIds.Double, BuiltInType.Double, 4), + ["matrix4d"] = new Entry( + DataTypeIds.UsdMatrix4d, Opc.Ua.DataTypeIds.Double, BuiltInType.Double, 16), + }; + + /// + /// Maps a USD value type name onto its OPC UA binding. + /// + /// The USD type name, for example float3, + /// token or color3f[]. A trailing [] adds one rank. + /// The server namespace table, used to resolve the + /// semantic DataTypes of this companion model. When null, a recognised role + /// type degrades to its built-in supertype, which is still a correct โ€” if less + /// specific โ€” binding. + /// The binding. An unrecognised type never throws: it degrades to an opaque + /// BaseDataType binding per ยง8.4. + public static UsdValueTypeMapping Map(string sdfValueTypeName, NamespaceTable? namespaceUris) + { + string name = Normalize(sdfValueTypeName, out bool isArray); + + if (!s_map.TryGetValue(name, out Entry? entry)) + { + // Unknown value type: carry it opaquely rather than dropping it (ยง8.4). + return new UsdValueTypeMapping( + Opc.Ua.DataTypeIds.BaseDataType, + isArray ? ValueRanks.OneDimension : ValueRanks.Scalar, + isArray ? new uint[] { 0 } : null, + BuiltInType.Variant, + isOpaque: true); + } + + NodeId dataTypeId = ResolveDataType(entry, namespaceUris); + bool isTuple = entry.FixedLength > 0; + + // A fixed-size math type is already a 1-D array; an explicit USD array adds one + // further rank, with the outer (variable) dimension reported as 0. + int rank; + uint[]? dimensions; + if (isTuple) + { + rank = isArray ? ValueRanks.TwoDimensions : ValueRanks.OneDimension; + dimensions = isArray + ? new uint[] { 0, entry.FixedLength } + : new uint[] { entry.FixedLength }; + } + else + { + rank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar; + dimensions = isArray ? new uint[] { 0 } : null; + } + + return new UsdValueTypeMapping(dataTypeId, rank, dimensions, entry.Element, isOpaque: false); + } + + /// + /// Whether the value type name is one this model maps to a concrete DataType. + /// + /// The USD type name. + /// true when the type is recognised. + public static bool IsKnown(string sdfValueTypeName) + { + return s_map.ContainsKey(Normalize(sdfValueTypeName, out _)); + } + + /// + /// The number of components a fixed-size USD math type decomposes into, or 0 when the + /// type is a scalar or unknown. matrix4d reports 16 (row-major flattened). + /// + /// The USD type name. + /// The component count. + public static uint ComponentCount(string sdfValueTypeName) + { + return s_map.TryGetValue(Normalize(sdfValueTypeName, out _), out Entry? entry) + ? entry.FixedLength + : 0; + } + + private static string Normalize(string? sdfValueTypeName, out bool isArray) + { + string name = (sdfValueTypeName ?? string.Empty).Trim(); + isArray = name.EndsWith("[]", StringComparison.Ordinal); + return isArray ? name.Substring(0, name.Length - 2) : name; + } + + private static NodeId ResolveDataType(Entry entry, NamespaceTable? namespaceUris) + { + ExpandedNodeId? semantic = entry.Semantic; + if (!semantic.HasValue || namespaceUris == null) + { + // Without a namespace table the semantic subtype cannot be addressed; the + // built-in supertype carries exactly the same value encoding. + return entry.BuiltIn; + } + NodeId resolved = ExpandedNodeId.ToNodeId(semantic.Value, namespaceUris); + return resolved.IsNull ? entry.BuiltIn : resolved; + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs new file mode 100644 index 0000000000..cee22e6995 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaReader.cs @@ -0,0 +1,2081 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Conversion +{ + /// + /// Parses a .usda text layer into a composed , per draft + /// OPC UA โ€” OpenUSD Scene Materialization ยง7.1 (import / materialize). + /// + /// + /// + /// This is the C# port of the specification reference converter's parse_usda reader and + /// its scoped composition (_apply_overlays, _merge, _remap_paths, + /// _clone) โ€” see metaverse-specs/extras/openusd-scene/tools/scene_common.py. It is + /// self-contained (no usd-core) and supports the subset of the .usda grammar the + /// specification example assets use. + /// + /// + /// Full USD composition is intentionally out of scope. reproduces the + /// reference converter's scoped composition for the published example layers: an + /// instanceable reference reconstructs its paired + /// arc, and referenced sub-assets (robot.usda, tool.usda) are resolved relative to + /// the referencing layer's directory and merged with path remapping. + /// + /// + public static +#if NET8_0_OR_GREATER + partial +#endif + class UsdaReader + { + private const string PrimPattern = "^\\s*(def|over|class)\\s+(?:(\\w+)\\s+)?\"([^\"]+)\"\\s*(\\(?)"; + private const string AttrPattern = "^\\s*(custom\\s+)?(uniform\\s+)?([\\w:\\[\\]]+)\\s+([\\w:]+)(\\.connect|\\.timeSamples)?\\s*(?:=\\s*(.*))?$"; + private const string RelPattern = "^\\s*(custom\\s+)?rel\\s+([\\w:]+)\\s*=\\s*(.*)$"; + private const string HeaderPattern = "#usda\\s+1\\.0\\s*\\("; + private const string TargetPattern = "<([^>]*)>"; + private const string JointPattern = "/J[1-6]$"; + private const string IntPattern = "^[-+]?\\d+$"; + private const string FloatPattern = "^[-+]?(\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?$"; + private const string DocTriplePattern = "doc\\s*=\\s*\"\"\"(.*?)\"\"\""; + private const string DocSinglePattern = "doc\\s*=\\s*\"([^\"]*)\""; + private const string KindPattern = "kind\\s*=\\s*\"([^\"]+)\""; + private const string ActivePattern = "active\\s*=\\s*(true|false)"; + private const string InstanceablePattern = "instanceable\\s*=\\s*(true|false)"; + private const string ApiSchemasPattern = "apiSchemas\\s*=\\s*\\[([^\\]]*)\\]"; + private const string QuotedTokenPattern = "\"([^\"]*)\""; + private const string ArcPattern = + "(?:(prepend|append|delete)\\s+)?(references|payload|inherits|specializes)" + + "\\s*=\\s*@([^@]+)@(?:<([^>]*)>)?"; + private const string VariantPattern = "variants\\s*=\\s*\\{\\s*string\\s+(\\w+)\\s*=\\s*\"([^\"]*)\"\\s*\\}"; + private const string VariantSetBlockPattern = "^\\s*variantSet\\s+\"([^\"]+)\"\\s*=\\s*\\{(.*)$"; + + // The regular-expression source generator (System.Text.RegularExpressions.GeneratedRegex) is + // only available on net8.0+. On the down-level library targets (net472/net48/netstandard2.1) + // fall back to cached, precompiled Regex instances so the same accessors compile everywhere. +#if NET8_0_OR_GREATER + [GeneratedRegex(PrimPattern, RegexOptions.CultureInvariant)] + private static partial Regex PrimRegex(); + + [GeneratedRegex(AttrPattern, RegexOptions.CultureInvariant)] + private static partial Regex AttrRegex(); + + [GeneratedRegex(RelPattern, RegexOptions.CultureInvariant)] + private static partial Regex RelRegex(); + + [GeneratedRegex(HeaderPattern, RegexOptions.CultureInvariant)] + private static partial Regex HeaderRegex(); + + [GeneratedRegex(TargetPattern, RegexOptions.CultureInvariant)] + private static partial Regex TargetRegex(); + + [GeneratedRegex(JointPattern, RegexOptions.CultureInvariant)] + private static partial Regex JointRegex(); + + [GeneratedRegex(IntPattern, RegexOptions.CultureInvariant)] + private static partial Regex IntRegex(); + + [GeneratedRegex(FloatPattern, RegexOptions.CultureInvariant)] + private static partial Regex FloatRegex(); + + [GeneratedRegex(DocTriplePattern, RegexOptions.Singleline | RegexOptions.CultureInvariant)] + private static partial Regex DocTripleRegex(); + + [GeneratedRegex(DocSinglePattern, RegexOptions.CultureInvariant)] + private static partial Regex DocSingleRegex(); + + [GeneratedRegex(KindPattern, RegexOptions.CultureInvariant)] + private static partial Regex KindRegex(); + + [GeneratedRegex(ActivePattern, RegexOptions.CultureInvariant)] + private static partial Regex ActiveRegex(); + + [GeneratedRegex(InstanceablePattern, RegexOptions.CultureInvariant)] + private static partial Regex InstanceableRegex(); + + [GeneratedRegex(ApiSchemasPattern, RegexOptions.CultureInvariant)] + private static partial Regex ApiSchemasRegex(); + + [GeneratedRegex(QuotedTokenPattern, RegexOptions.CultureInvariant)] + private static partial Regex QuotedTokenRegex(); + + [GeneratedRegex(ArcPattern, RegexOptions.CultureInvariant)] + private static partial Regex ArcRegex(); + + [GeneratedRegex(VariantPattern, RegexOptions.CultureInvariant)] + private static partial Regex VariantRegex(); + + [GeneratedRegex(VariantSetBlockPattern, RegexOptions.CultureInvariant)] + private static partial Regex VariantSetBlockRegex(); +#else + private static readonly Regex s_primRegex = new Regex(PrimPattern, RegexOptions.CultureInvariant); + private static Regex PrimRegex() => s_primRegex; + + private static readonly Regex s_attrRegex = new Regex(AttrPattern, RegexOptions.CultureInvariant); + private static Regex AttrRegex() => s_attrRegex; + + private static readonly Regex s_relRegex = new Regex(RelPattern, RegexOptions.CultureInvariant); + private static Regex RelRegex() => s_relRegex; + + private static readonly Regex s_headerRegex = new Regex(HeaderPattern, RegexOptions.CultureInvariant); + private static Regex HeaderRegex() => s_headerRegex; + + private static readonly Regex s_targetRegex = new Regex(TargetPattern, RegexOptions.CultureInvariant); + private static Regex TargetRegex() => s_targetRegex; + + private static readonly Regex s_jointRegex = new Regex(JointPattern, RegexOptions.CultureInvariant); + private static Regex JointRegex() => s_jointRegex; + + private static readonly Regex s_intRegex = new Regex(IntPattern, RegexOptions.CultureInvariant); + private static Regex IntRegex() => s_intRegex; + + private static readonly Regex s_floatRegex = new Regex(FloatPattern, RegexOptions.CultureInvariant); + private static Regex FloatRegex() => s_floatRegex; + + private static readonly Regex s_docTripleRegex = + new Regex(DocTriplePattern, RegexOptions.Singleline | RegexOptions.CultureInvariant); + private static Regex DocTripleRegex() => s_docTripleRegex; + + private static readonly Regex s_docSingleRegex = new Regex(DocSinglePattern, RegexOptions.CultureInvariant); + private static Regex DocSingleRegex() => s_docSingleRegex; + + private static readonly Regex s_kindRegex = new Regex(KindPattern, RegexOptions.CultureInvariant); + private static Regex KindRegex() => s_kindRegex; + + private static readonly Regex s_activeRegex = new Regex(ActivePattern, RegexOptions.CultureInvariant); + private static Regex ActiveRegex() => s_activeRegex; + + private static readonly Regex s_instanceableRegex = new Regex(InstanceablePattern, RegexOptions.CultureInvariant); + private static Regex InstanceableRegex() => s_instanceableRegex; + + private static readonly Regex s_apiSchemasRegex = new Regex(ApiSchemasPattern, RegexOptions.CultureInvariant); + private static Regex ApiSchemasRegex() => s_apiSchemasRegex; + + private static readonly Regex s_quotedTokenRegex = new Regex(QuotedTokenPattern, RegexOptions.CultureInvariant); + private static Regex QuotedTokenRegex() => s_quotedTokenRegex; + + private static readonly Regex s_arcRegex = new Regex(ArcPattern, RegexOptions.CultureInvariant); + private static Regex ArcRegex() => s_arcRegex; + + private static readonly Regex s_variantRegex = new Regex(VariantPattern, RegexOptions.CultureInvariant); + private static Regex VariantRegex() => s_variantRegex; + + private static readonly Regex s_variantSetBlockRegex = + new Regex(VariantSetBlockPattern, RegexOptions.CultureInvariant); + private static Regex VariantSetBlockRegex() => s_variantSetBlockRegex; +#endif + + /// + /// Parses a .usda file into a composed stage. + /// + /// The path to the .usda layer. + /// The stage name; defaults to the file name without extension. + /// When true (the default), applies the reference + /// converter's scoped composition for the published example layers (pumps, robotics). Set to + /// false when parsing an already-flattened composed layer (for example a layer emitted + /// by ) to avoid composing overlays twice. + /// The composed stage. + public static UsdStage ParseFile(string path, string? stageName = null, bool applyExampleOverlays = true) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + string text = File.ReadAllText(path); + string name = stageName ?? Path.GetFileNameWithoutExtension(path); + UsdStage stage = ParseCore(text, name, path, applyExampleOverlays); + stage.RootLayerIdentifier = Path.GetFileName(path); + return stage; + } + + /// + /// Parses a .usda layer supplied as text into a composed stage. + /// + /// The .usda layer text. + /// The stage name. + /// The composed stage. Example overlays are not applied โ€” the text is treated as an + /// already-flattened composed layer. + public static UsdStage Parse(string text, string stageName) + { + if (text == null) + { + throw new ArgumentNullException(nameof(text)); + } + return ParseCore(text, stageName ?? string.Empty, null, applyExampleOverlays: false); + } + + private static UsdStage ParseCore(string rawText, string stageName, string? sourcePath, bool applyExampleOverlays) + { + // Layer metadata is read from the raw text: the '#usda 1.0 ( ... )' header is a magic + // token, not a comment, and the paren scan is string-aware, so it survives untouched. + // Comments are then stripped only from the prim body that follows. + string rawBody = ReadLayerMetadata(rawText, out var meta); + string body = StripComments(rawBody); + + var stage = new UsdStage(stageName) + { + DefaultPrim = meta.DefaultPrim, + UpAxis = meta.UpAxis, + MetersPerUnit = meta.MetersPerUnit, + KilogramsPerUnit = meta.KilogramsPerUnit, + TimeCodesPerSecond = meta.TimeCodesPerSecond, + StartTimeCode = meta.StartTimeCode, + EndTimeCode = meta.EndTimeCode, + Documentation = meta.Documentation, + }; + + ParseBody(body, stage); + + if (applyExampleOverlays && sourcePath != null) + { + ApplyExampleOverlays(stage, sourcePath); + } + return stage; + } + + private static void ParseBody(string body, UsdStage stage) + { + var lines = new List(); + foreach (string raw in body.Split('\n')) + { + string line = raw.TrimEnd(); + if (line.Trim().Length > 0) + { + lines.Add(line); + } + } + + var stack = new List(); + UsdPrim? pending = null; + bool inMeta = false; + var metaLines = new List(); + + // Nesting depth inside a prim's '( ... )' metadata block, tracked across lines so a ')' + // inside a quoted string, a tuple/array or a customData dictionary never closes the + // block prematurely (ยง6.3). Only a ')' seen at depth 0 ends the metadata. + int metaDepth = 0; + + // A multi-line '.timeSamples = { ... }' block is accumulated across lines here so its + // braces are never mistaken for prim scope braces. + UsdAttribute? samplesTarget = null; + var samplesBuffer = new StringBuilder(); + + // A 'variantSet "name" = { ... }' block spans several lines and nests prim/attribute + // braces; it is accumulated here (tracking brace depth) so those inner braces never pop + // the prim scope stack, then parsed into full variant branches once the block closes. + UsdVariantSet? variantSetTarget = null; + var variantSetBuffer = new StringBuilder(); + int variantSetDepth = 0; + + foreach (string ln in lines) + { + if (samplesTarget != null) + { + int closeBrace = FindUnquoted(ln, '}'); + if (closeBrace >= 0) + { + samplesBuffer.Append(ln, 0, closeBrace); + ParseTimeSamples(samplesBuffer.ToString(), samplesTarget); + samplesTarget = null; + samplesBuffer.Clear(); + } + else + { + samplesBuffer.Append(ln).Append('\n'); + } + continue; + } + + if (variantSetTarget != null) + { + if (AccumulateVariantSetBlock(ln, variantSetBuffer, ref variantSetDepth)) + { + foreach (UsdPrim branch in ParseVariantBranches(variantSetBuffer.ToString())) + { + variantSetTarget.Variants.Add(branch); + } + variantSetTarget = null; + variantSetBuffer.Clear(); + variantSetDepth = 0; + } + continue; + } + + if (pending != null && inMeta) + { + int closeParen = ScanPrimMetaClose(ln, ref metaDepth); + if (closeParen >= 0) + { + metaLines.Add(ln.Substring(0, closeParen)); + inMeta = false; + ApplyPrimMeta(pending, metaLines); + metaLines = new List(); + if (FindUnquoted(ln.Substring(closeParen + 1), '{') >= 0) + { + Attach(stage, stack, pending); + pending = null; + } + continue; + } + metaLines.Add(ln); + continue; + } + + Match pm = PrimRegex().Match(ln); + if (pm.Success) + { + string spec = pm.Groups[1].Value; + string typ = pm.Groups[2].Value; + string name = pm.Groups[3].Value; + bool paren = pm.Groups[4].Value.Length > 0; + + pending = new UsdPrim(name, typ) + { + Specifier = spec switch + { + "over" => UsdSpecifierEnum.Over, + "class" => UsdSpecifierEnum.Class, + _ => UsdSpecifierEnum.Def, + }, + }; + + if (paren || FindUnquoted(ln, '(') >= 0) + { + int openParen = FindUnquoted(ln, '('); + string after = openParen >= 0 ? ln.Substring(openParen + 1) : string.Empty; + metaDepth = 0; + int closeParen = ScanPrimMetaClose(after, ref metaDepth); + if (closeParen >= 0) + { + metaLines = new List { after.Substring(0, closeParen) }; + ApplyPrimMeta(pending, metaLines); + metaLines = new List(); + if (FindUnquoted(after.Substring(closeParen + 1), '{') >= 0) + { + Attach(stage, stack, pending); + pending = null; + } + } + else + { + inMeta = true; + metaLines = new List { after }; + } + } + else if (ContainsChar(ln, '{')) + { + Attach(stage, stack, pending); + pending = null; + } + continue; + } + + if (pending != null && ContainsChar(ln, '{')) + { + Attach(stage, stack, pending); + pending = null; + continue; + } + + if (stack.Count > 0) + { + Match vsm = VariantSetBlockRegex().Match(ln); + if (vsm.Success) + { + string setName = vsm.Groups[1].Value; + UsdPrim owner = stack[stack.Count - 1]; + UsdVariantSet? set = owner.VariantSets.FirstOrDefault( + v => string.Equals(v.SetName, setName, StringComparison.Ordinal)); + if (set == null) + { + set = new UsdVariantSet(setName); + owner.VariantSets.Add(set); + } + + variantSetTarget = set; + variantSetBuffer.Clear(); + variantSetDepth = 1; + if (AccumulateVariantSetBlock(vsm.Groups[2].Value, variantSetBuffer, ref variantSetDepth)) + { + foreach (UsdPrim branch in ParseVariantBranches(variantSetBuffer.ToString())) + { + set.Variants.Add(branch); + } + variantSetTarget = null; + variantSetBuffer.Clear(); + variantSetDepth = 0; + } + continue; + } + } + + if (ContainsChar(ln, '}')) + { + // A single-line '.timeSamples = { ... }' declaration carries a '}' that closes + // the sample block, not a prim scope; route it to attribute handling below + // instead of popping the stack. + Match tsm = AttrRegex().Match(ln); + if (!tsm.Success + || !string.Equals(tsm.Groups[5].Value, ".timeSamples", StringComparison.Ordinal)) + { + int count = ln.Count(c => c == '}'); + for (int i = 0; i < count; i++) + { + if (stack.Count > 0) + { + stack.RemoveAt(stack.Count - 1); + } + } + continue; + } + } + + if (stack.Count == 0) + { + continue; + } + + UsdPrim cur = stack[stack.Count - 1]; + + Match rm = RelRegex().Match(ln); + if (rm.Success) + { + var rel = new UsdRelationship(rm.Groups[2].Value) + { + Custom = rm.Groups[1].Value.Length > 0, + }; + foreach (string target in ParseTargets(rm.Groups[3].Value)) + { + rel.Targets.Add(target); + } + cur.Relationships.Add(rel); + continue; + } + + Match am = AttrRegex().Match(ln); + if (am.Success) + { + bool custom = am.Groups[1].Value.Length > 0; + bool uniform = am.Groups[2].Value.Length > 0; + string typeName = am.Groups[3].Value; + string attrName = am.Groups[4].Value; + string suffix = am.Groups[5].Value; + string? valueText = am.Groups[6].Success ? am.Groups[6].Value : null; + bool connect = string.Equals(suffix, ".connect", StringComparison.Ordinal); + bool timeSamples = string.Equals(suffix, ".timeSamples", StringComparison.Ordinal); + + if (timeSamples) + { + // The default (Value) and its time samples may be authored on separate + // lines that share one attribute name, so attach the samples to the + // existing attribute where present rather than declaring a duplicate. + UsdAttribute target = FindOrCreateAttribute(cur, attrName, typeName, uniform, custom); + string block = valueText ?? string.Empty; + int open = FindUnquoted(block, '{'); + string afterOpen = open >= 0 ? block.Substring(open + 1) : string.Empty; + int close = FindUnquoted(afterOpen, '}'); + if (close >= 0) + { + ParseTimeSamples(afterOpen.Substring(0, close), target); + } + else + { + samplesTarget = target; + samplesBuffer.Clear(); + samplesBuffer.Append(afterOpen).Append('\n'); + } + continue; + } + + // A default value, its time samples and its connections may be co-authored on + // separate lines that share one attribute name, so coalesce them onto a single + // UsdAttribute (found by name, or created) rather than declaring a duplicate. + // USD permits a default value co-authored with a connection (ยง5.4, ยง7.1). + UsdAttribute attr = FindOrCreateNamedAttribute(cur, attrName, typeName, uniform, custom); + if (connect) + { + // A '.connect' authors either a single '' or a bracketed + // '[, ]' list; preserve every target in authored order (ยง5.4). + foreach (string target in ParseConnectionTargets(valueText)) + { + attr.Connections.Add(target); + } + } + else + { + attr.Value = ParseValue(valueText); + } + } + } + } + + private static void Attach(UsdStage stage, List stack, UsdPrim prim) + { + if (stack.Count > 0) + { + stack[stack.Count - 1].AddChild(prim); + } + else + { + stage.AddRootPrim(prim); + } + stack.Add(prim); + } + + /// + /// Appends the in-scope characters of a variantSet "name" = { ... } block chunk to + /// while tracking brace , honouring + /// double-quoted spans and @asset@ spans so their braces are never counted. + /// + /// The next slice of the block (already past the opening brace). + /// Accumulates the block's inner content (excluding the outer braces). + /// The running brace depth; starts at 1 for the opening brace. + /// true when the block's closing brace is reached (depth returns to 0). + private static bool AccumulateVariantSetBlock(string chunk, StringBuilder buffer, ref int depth) + { + bool inQuote = false; + bool inAsset = false; + foreach (char ch in chunk) + { + if (ch == '"' && !inAsset) + { + inQuote = !inQuote; + buffer.Append(ch); + continue; + } + if (ch == '@' && !inQuote) + { + inAsset = !inAsset; + buffer.Append(ch); + continue; + } + if (!inQuote && !inAsset) + { + if (ch == '{') + { + depth++; + buffer.Append(ch); + continue; + } + if (ch == '}') + { + depth--; + if (depth <= 0) + { + return true; + } + buffer.Append(ch); + continue; + } + } + buffer.Append(ch); + } + buffer.Append('\n'); + return false; + } + + /// + /// Parses the inner content of a variantSet block into its variant branches. Each + /// "branch" { ... } body is re-parsed by wrapping it as a def prim and running + /// the same body parser, so a branch may itself carry attributes, relationships, child prims + /// and even nested variant sets (ยง5.6). Branch order is preserved. + /// + /// The block interior (the text between the outer braces). + /// The parsed branches, in authored order. + private static List ParseVariantBranches(string blockContent) + { + var branches = new List(); + int i = 0; + int n = blockContent.Length; + while (i < n) + { + while (i < n && blockContent[i] != '"') + { + i++; + } + if (i >= n) + { + break; + } + int nameStart = i + 1; + int nameEnd = blockContent.IndexOf('"', nameStart); + if (nameEnd < 0) + { + break; + } + string branchName = blockContent.Substring(nameStart, nameEnd - nameStart); + + int j = nameEnd + 1; + while (j < n && char.IsWhiteSpace(blockContent[j])) + { + j++; + } + // A branch may carry its own '( ... )' metadata before its body; skip it as a + // balanced span so any braces inside the metadata are not mistaken for the body. + if (j < n && blockContent[j] == '(') + { + j = SkipBalanced(blockContent, j, '(', ')'); + while (j < n && char.IsWhiteSpace(blockContent[j])) + { + j++; + } + } + if (j >= n || blockContent[j] != '{') + { + i = nameEnd + 1; + continue; + } + + int bodyStart = j + 1; + int bodyEnd = SkipBalanced(blockContent, j, '{', '}') - 1; + if (bodyEnd < bodyStart) + { + break; + } + string body = blockContent.Substring(bodyStart, bodyEnd - bodyStart); + + var temp = new UsdStage("variant"); + ParseBody("def \"" + branchName + "\" {\n" + body + "\n}", temp); + UsdPrim branch = temp.RootPrims.Count > 0 + ? temp.RootPrims[0] + : new UsdPrim(branchName); + branch.Parent = null; + branches.Add(branch); + i = bodyEnd + 1; + } + return branches; + } + + /// + /// Returns the index just past the matching close delimiter for the open delimiter at + /// , honouring double-quoted and @asset@ spans. + /// + private static int SkipBalanced(string text, int start, char open, char close) + { + int depth = 0; + bool inQuote = false; + bool inAsset = false; + for (int k = start; k < text.Length; k++) + { + char ch = text[k]; + if (ch == '"' && !inAsset) + { + inQuote = !inQuote; + } + else if (ch == '@' && !inQuote) + { + inAsset = !inAsset; + } + else if (!inQuote && !inAsset) + { + if (ch == open) + { + depth++; + } + else if (ch == close) + { + depth--; + if (depth == 0) + { + return k + 1; + } + } + } + } + return text.Length; + } + + /// + /// Removes # line comments, honouring double-quoted spans (port of + /// _strip_comments). Quote state is tracked per line, matching the reference. + /// + /// The raw layer text. + /// The text with comments removed. + internal static string StripComments(string text) + { + var outLines = new List(); + foreach (string rawLine in text.Split('\n')) + { + string line = rawLine.TrimEnd('\r'); + bool inQuote = false; + var sb = new StringBuilder(line.Length); + foreach (char ch in line) + { + if (ch == '"') + { + inQuote = !inQuote; + } + if (ch == '#' && !inQuote) + { + break; + } + sb.Append(ch); + } + outLines.Add(sb.ToString()); + } + return string.Join("\n", outLines); + } + + /// + /// Parses a scalar attribute value token into a CLR value (port of _parse_value). + /// + /// + /// Integers become , floating point values , + /// tuples (a, b, c) become object?[], arrays [a, b, c] become + /// List<object?>, path references </Path> and asset paths + /// @asset@ become the inner string, and unquoted words become token strings. + /// + /// The raw value text, or null. + /// The parsed value, or null when the text is empty. + internal static object? ParseValue(string? raw) + { + string v = (raw ?? string.Empty).Trim().TrimEnd(',').Trim(); + if (v.Length == 0) + { + return null; + } + if (v.Length >= 2 && v[0] == '<' && v[v.Length - 1] == '>') + { + return v.Substring(1, v.Length - 2); + } + if (v.Length >= 2 && v[0] == '@' && v[v.Length - 1] == '@') + { + return v.Substring(1, v.Length - 2); + } + if (string.Equals(v, "true", StringComparison.Ordinal)) + { + return true; + } + if (string.Equals(v, "false", StringComparison.Ordinal)) + { + return false; + } + if (TryParseLiteral(v, out object? literal)) + { + return literal; + } + if (IntRegex().IsMatch(v)) + { + return long.Parse(v, CultureInfo.InvariantCulture); + } + if (FloatRegex().IsMatch(v)) + { + return double.Parse(v, NumberStyles.Float, CultureInfo.InvariantCulture); + } + return v.Trim('"'); + } + + private static List ParseTargets(string raw) + { + var targets = new List(); + foreach (Match m in TargetRegex().Matches(raw)) + { + targets.Add(m.Groups[1].Value); + } + if (targets.Count == 0) + { + string trimmed = raw.Trim(); + if (trimmed.Length > 0 && !string.Equals(trimmed, "[]", StringComparison.Ordinal)) + { + object? parsed = ParseValue(raw); + if (parsed is string single) + { + targets.Add(single); + } + else if (parsed is List list) + { + foreach (object? item in list) + { + if (item is string s) + { + targets.Add(s); + } + } + } + } + } + return targets; + } + + /// + /// Parses the right-hand side of an attribute .connect into its ordered targets. + /// Accepts a single bare path reference (<target>), a bracketed path-reference + /// list ([<t1>, <t2>]) authored by the writer for several targets, and an + /// empty list ([]). Preserves authored order (ยง5.4). + /// + private static List ParseConnectionTargets(string? valueText) + { + var targets = new List(); + object? parsed = ParseValue(valueText); + if (parsed is string single) + { + targets.Add(single); + } + else if (parsed is List list) + { + foreach (object? item in list) + { + if (item is string s) + { + targets.Add(s); + } + } + } + return targets; + } + + /// + /// Finds the most recent attribute on with the given name that has + /// no time samples yet, or creates and attaches a new one. Lets a .timeSamples block + /// join the attribute an authored default declared, so one attribute carries both (ยง7.1). + /// + private static UsdAttribute FindOrCreateAttribute( + UsdPrim prim, string name, string typeName, bool uniform, bool custom) + { + for (int i = prim.Attributes.Count - 1; i >= 0; i--) + { + UsdAttribute existing = prim.Attributes[i]; + if (string.Equals(existing.Name, name, StringComparison.Ordinal) + && existing.TimeSamples.Count == 0) + { + return existing; + } + } + var created = new UsdAttribute(name, typeName) + { + Variability = uniform ? UsdVariabilityEnum.Uniform : UsdVariabilityEnum.Varying, + Custom = custom, + }; + prim.Attributes.Add(created); + return created; + } + + /// + /// Finds the most recent attribute on with the given name โ€” + /// regardless of whether it already carries a default, time samples or connections โ€” or + /// creates and attaches a new one. USD (and this writer) author an attribute's default + /// value, its time samples and its '.connect' targets on separate lines that share one + /// attribute name; coalescing every facet onto a single keeps a + /// value co-authored with a connection as one attribute rather than splitting it into two, + /// so the composed scene (and its signature) reflect what was authored (ยง5.4, ยง7.1). + /// + private static UsdAttribute FindOrCreateNamedAttribute( + UsdPrim prim, string name, string typeName, bool uniform, bool custom) + { + for (int i = prim.Attributes.Count - 1; i >= 0; i--) + { + if (string.Equals(prim.Attributes[i].Name, name, StringComparison.Ordinal)) + { + return prim.Attributes[i]; + } + } + var created = new UsdAttribute(name, typeName) + { + Variability = uniform ? UsdVariabilityEnum.Uniform : UsdVariabilityEnum.Varying, + Custom = custom, + }; + prim.Attributes.Add(created); + return created; + } + + /// + /// Parses the body of a .timeSamples = { โ€ฆ } block (the text between the braces) into + /// the attribute's ordered sample map. Each entry is timeCode: value, comma separated; + /// the time code may be negative or fractional and the value may be any shape the reader + /// otherwise supports (scalar, tuple, array, asset path, token). Later samples for the same + /// time code overwrite earlier ones, matching USD's composed semantics. + /// + private static void ParseTimeSamples(string inner, UsdAttribute attr) + { + int depth = 0; + bool inQuote = false; + char quote = '\0'; + bool inAsset = false; + int start = 0; + int colon = -1; + for (int i = 0; i <= inner.Length; i++) + { + bool atEnd = i == inner.Length; + char c = atEnd ? '\0' : inner[i]; + if (!atEnd && inQuote) + { + if (c == '\\') + { + i++; + } + else if (c == quote) + { + inQuote = false; + } + continue; + } + if (!atEnd && inAsset) + { + if (c == '@') + { + inAsset = false; + } + continue; + } + if (atEnd || (c == ',' && depth == 0)) + { + AddTimeSample(attr, inner, start, i, colon); + start = i + 1; + colon = -1; + continue; + } + switch (c) + { + case '"': + case '\'': + inQuote = true; + quote = c; + break; + case '@': + inAsset = true; + break; + case '(': + case '[': + case '<': + depth++; + break; + case ')': + case ']': + case '>': + if (depth > 0) + { + depth--; + } + break; + case ':': + if (depth == 0 && colon < 0) + { + colon = i; + } + break; + } + } + } + + private static void AddTimeSample(UsdAttribute attr, string inner, int start, int end, int colon) + { + if (colon < start || colon >= end) + { + // No 'timeCode: value' separator: an empty entry (trailing comma) or malformed. + return; + } + string timeText = inner.Substring(start, colon - start).Trim(); + string valueText = inner.Substring(colon + 1, end - colon - 1).Trim(); + if (timeText.Length == 0 + || !double.TryParse(timeText, NumberStyles.Float, CultureInfo.InvariantCulture, out double timeCode)) + { + // Fail closed: never publish a sample under an unparseable time code. + return; + } + attr.TimeSamples[timeCode] = ParseValue(valueText); + } + + // Scans one line of a prim '( ... )' metadata block and returns the index of the ')' that + // closes the block, or -1 when the block continues on a later line. Honours double/single + // quoted spans and '@โ€ฆ@' asset spans, and tracks nesting of (), [] and {} across lines via + // (the metadata's own opening paren is depth 0, already consumed), + // so a ')' inside a quoted string, a tuple/array or a customData dictionary never closes the + // block prematurely (ยง6.3). Callers reset depth to 0 before scanning a new block's first + // line and carry the updated value into the scan of each continuation line. + private static int ScanPrimMetaClose(string line, ref int depth) + { + bool inQuote = false; + char quote = '\0'; + bool inAsset = false; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (inQuote) + { + if (c == '\\') + { + i++; + } + else if (c == quote) + { + inQuote = false; + } + continue; + } + if (inAsset) + { + if (c == '@') + { + inAsset = false; + } + continue; + } + switch (c) + { + case '"': + case '\'': + inQuote = true; + quote = c; + break; + case '@': + inAsset = true; + break; + case '(': + case '[': + case '{': + depth++; + break; + case ']': + case '}': + if (depth > 0) + { + depth--; + } + break; + case ')': + if (depth == 0) + { + return i; + } + depth--; + break; + } + } + return -1; + } + + // Returns the index of the first occurrence of that is not inside + // a double/single-quoted span or an '@โ€ฆ@' asset delimiter, or -1. Used to find a + // '.timeSamples' block's closing brace without tripping on a braced character in a string. + private static int FindUnquoted(string line, char target) + { + bool inQuote = false; + char quote = '\0'; + bool inAsset = false; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (inQuote) + { + if (c == '\\') + { + i++; + } + else if (c == quote) + { + inQuote = false; + } + continue; + } + if (inAsset) + { + if (c == '@') + { + inAsset = false; + } + continue; + } + if (c == '"' || c == '\'') + { + inQuote = true; + quote = c; + } + else if (c == '@') + { + inAsset = true; + } + else if (c == target) + { + return i; + } + } + return -1; + } + + // ----- literal parsing (numbers / quoted strings / tuples / arrays) ----- + + private static bool TryParseLiteral(string s, out object? result) + { + int pos = 0; + if (!TryParseLiteralValue(s, ref pos, out result)) + { + return false; + } + SkipWhitespace(s, ref pos); + return pos == s.Length; + } + + private static bool TryParseLiteralValue(string s, ref int pos, out object? result) + { + result = null; + SkipWhitespace(s, ref pos); + if (pos >= s.Length) + { + return false; + } + char c = s[pos]; + if (c == '(') + { + return TryParseSequence(s, ref pos, '(', ')', asTuple: true, out result); + } + if (c == '[') + { + return TryParseSequence(s, ref pos, '[', ']', asTuple: false, out result); + } + if (c == '"' || c == '\'') + { + return TryParseQuoted(s, ref pos, out result); + } + if (c == '@') + { + return TryParseAssetReference(s, ref pos, out result); + } + if (c == '<') + { + return TryParsePathReference(s, ref pos, out result); + } + if (c == '-' || c == '+' || c == '.' || (c >= '0' && c <= '9')) + { + return TryParseNumber(s, ref pos, out result); + } + return false; + } + + // A USD asset-path element inside a bracketed array literal is authored '@path@' + // (symmetric with the writer's asset[] rendering, ยง6.2), so accept it as a leaf. + private static bool TryParseAssetReference(string s, ref int pos, out object? result) + { + result = null; + int end = s.IndexOf('@', pos + 1); + if (end < 0) + { + return false; + } + result = s.Substring(pos + 1, end - pos - 1); + pos = end + 1; + return true; + } + + // A path-reference element inside a bracketed list is authored '' โ€” used by a + // multi-target '.connect' list (ยง5.4). Accept it so the list re-parses to its targets. + private static bool TryParsePathReference(string s, ref int pos, out object? result) + { + result = null; + int end = s.IndexOf('>', pos + 1); + if (end < 0) + { + return false; + } + result = s.Substring(pos + 1, end - pos - 1); + pos = end + 1; + return true; + } + + private static bool TryParseSequence(string s, ref int pos, char open, char close, bool asTuple, out object? result) + { + result = null; + pos++; // consume opening bracket + var items = new List(); + SkipWhitespace(s, ref pos); + if (pos < s.Length && s[pos] == close) + { + pos++; + result = asTuple ? (object)items.ToArray() : items; + return true; + } + while (true) + { + if (!TryParseLiteralValue(s, ref pos, out object? item)) + { + return false; + } + items.Add(item); + SkipWhitespace(s, ref pos); + if (pos >= s.Length) + { + return false; + } + if (s[pos] == ',') + { + pos++; + SkipWhitespace(s, ref pos); + if (pos < s.Length && s[pos] == close) + { + pos++; + break; + } + continue; + } + if (s[pos] == close) + { + pos++; + break; + } + return false; + } + result = asTuple ? (object)items.ToArray() : items; + return true; + } + + private static bool TryParseQuoted(string s, ref int pos, out object? result) + { + result = null; + char quote = s[pos]; + pos++; + var sb = new StringBuilder(); + while (pos < s.Length) + { + char c = s[pos]; + if (c == '\\' && pos + 1 < s.Length) + { + char next = s[pos + 1]; + sb.Append(next switch + { + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + _ => next, + }); + pos += 2; + continue; + } + if (c == quote) + { + pos++; + result = sb.ToString(); + return true; + } + sb.Append(c); + pos++; + } + return false; + } + + private static bool TryParseNumber(string s, ref int pos, out object? result) + { + result = null; + int start = pos; + while (pos < s.Length) + { + char c = s[pos]; + if ((c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || c == 'e' || c == 'E') + { + pos++; + continue; + } + break; + } + string token = s.Substring(start, pos - start); + if (IntRegex().IsMatch(token)) + { + result = long.Parse(token, CultureInfo.InvariantCulture); + return true; + } + if (FloatRegex().IsMatch(token)) + { + result = double.Parse(token, NumberStyles.Float, CultureInfo.InvariantCulture); + return true; + } + return false; + } + + private static void SkipWhitespace(string s, ref int pos) + { + while (pos < s.Length && char.IsWhiteSpace(s[pos])) + { + pos++; + } + } + + // ----- layer metadata ----- + + private readonly struct LayerMeta + { + public LayerMeta(string documentation, string defaultPrim, string upAxis, double metersPerUnit, + double? kilogramsPerUnit, double? timeCodesPerSecond, double? startTimeCode, double? endTimeCode) + { + Documentation = documentation; + DefaultPrim = defaultPrim; + UpAxis = upAxis; + MetersPerUnit = metersPerUnit; + KilogramsPerUnit = kilogramsPerUnit; + TimeCodesPerSecond = timeCodesPerSecond; + StartTimeCode = startTimeCode; + EndTimeCode = endTimeCode; + } + + public string Documentation { get; } + public string DefaultPrim { get; } + public string UpAxis { get; } + public double MetersPerUnit { get; } + public double? KilogramsPerUnit { get; } + public double? TimeCodesPerSecond { get; } + public double? StartTimeCode { get; } + public double? EndTimeCode { get; } + } + + private static string ReadLayerMetadata(string text, out LayerMeta meta) + { + meta = new LayerMeta(string.Empty, string.Empty, "Z", 1.0, null, null, null, null); + + Match header = HeaderRegex().Match(text); + if (!header.Success) + { + return text; + } + int open = header.Index + header.Length - 1; + if (!TryFindMatchingParen(text, open, out int close)) + { + return text; + } + string block = text.Substring(open + 1, close - open - 1); + + string doc = ExtractDoc(block); + string defaultPrim = ExtractQuoted(block, "defaultPrim"); + string upAxisValue = ExtractQuoted(block, "upAxis"); + meta = new LayerMeta( + doc, + defaultPrim, + upAxisValue.Length > 0 ? upAxisValue : "Z", + ExtractNumber(block, "metersPerUnit") ?? 1.0, + ExtractNumber(block, "kilogramsPerUnit"), + ExtractNumber(block, "timeCodesPerSecond"), + ExtractNumber(block, "startTimeCode"), + ExtractNumber(block, "endTimeCode")); + + return text.Substring(close + 1); + } + + private static bool TryFindMatchingParen(string text, int openIndex, out int closeIndex) + { + closeIndex = -1; + int i = openIndex + 1; + bool inTriple = false; + bool inQuote = false; + while (i < text.Length) + { + if (inTriple) + { + if (IsTripleQuote(text, i)) + { + inTriple = false; + i += 3; + continue; + } + i++; + continue; + } + if (inQuote) + { + if (text[i] == '\\') + { + i += 2; + continue; + } + if (text[i] == '"') + { + inQuote = false; + } + i++; + continue; + } + if (IsTripleQuote(text, i)) + { + inTriple = true; + i += 3; + continue; + } + if (text[i] == '"') + { + inQuote = true; + i++; + continue; + } + if (text[i] == ')') + { + closeIndex = i; + return true; + } + i++; + } + return false; + } + + private static bool IsTripleQuote(string text, int i) + { + return i + 3 <= text.Length && text[i] == '"' && text[i + 1] == '"' && text[i + 2] == '"'; + } + + private static string ExtractDoc(string block) + { + Match triple = DocTripleRegex().Match(block); + if (triple.Success) + { + return triple.Groups[1].Value.Trim(); + } + Match single = DocSingleRegex().Match(block); + return single.Success ? single.Groups[1].Value.Trim() : string.Empty; + } + + private static string ExtractQuoted(string block, string key) + { + Match m = Regex.Match(block, Regex.Escape(key) + "\\s*=\\s*\"([^\"]*)\""); + return m.Success ? m.Groups[1].Value : string.Empty; + } + + private static double? ExtractNumber(string block, string key) + { + Match m = Regex.Match(block, Regex.Escape(key) + "\\s*=\\s*([-+0-9.eE]+)"); + if (m.Success && double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double d)) + { + return d; + } + return null; + } + + // ----- prim metadata ----- + + private static void ApplyPrimMeta(UsdPrim prim, List metaLines) + { + string block = string.Join("\n", metaLines); + + Match doc = DocTripleRegex().Match(block); + if (doc.Success) + { + prim.Documentation = doc.Groups[1].Value.Trim(); + } + else + { + Match docSingle = DocSingleRegex().Match(block); + if (docSingle.Success) + { + prim.Documentation = docSingle.Groups[1].Value.Trim(); + } + } + + Match kind = KindRegex().Match(block); + if (kind.Success) + { + prim.Kind = ParseKind(kind.Groups[1].Value); + } + + Match active = ActiveRegex().Match(block); + if (active.Success) + { + prim.Active = string.Equals(active.Groups[1].Value, "true", StringComparison.Ordinal); + } + + Match instance = InstanceableRegex().Match(block); + bool instanceable = instance.Success && string.Equals(instance.Groups[1].Value, "true", StringComparison.Ordinal); + prim.Instanceable = instanceable; + + Match apis = ApiSchemasRegex().Match(block); + if (apis.Success) + { + foreach (Match schema in QuotedTokenRegex().Matches(apis.Groups[1].Value)) + { + prim.ApiSchemas.Add(new UsdApiSchema(schema.Groups[1].Value)); + } + } + + foreach (Match arc in ArcRegex().Matches(block)) + { + UsdArcKindEnum arcKind = arc.Groups[2].Value switch + { + "payload" => UsdArcKindEnum.Payload, + "inherits" => UsdArcKindEnum.Inherit, + "specializes" => UsdArcKindEnum.Specialize, + _ => UsdArcKindEnum.Reference, + }; + prim.Composition.Add(new UsdCompositionArc(arcKind) + { + AssetPath = arc.Groups[3].Value, + PrimPath = arc.Groups[4].Success ? arc.Groups[4].Value : string.Empty, + ListPosition = ParseListPosition(arc.Groups[1].Success ? arc.Groups[1].Value : "prepend"), + }); + } + + foreach (Match variant in VariantRegex().Matches(block)) + { + prim.VariantSets.Add(new UsdVariantSet(variant.Groups[1].Value, variant.Groups[2].Value)); + } + + if (instanceable) + { + UsdCompositionArc? reference = prim.Composition.FirstOrDefault( + a => a.ArcKind == UsdArcKindEnum.Reference || a.ArcKind == UsdArcKindEnum.Payload); + if (reference != null) + { + prim.Composition.Add(new UsdCompositionArc(UsdArcKindEnum.Instance) + { + AssetPath = reference.AssetPath, + PrimPath = reference.PrimPath, + ListPosition = reference.ListPosition, + }); + } + } + + ApplyCustomPrimMeta(prim, block); + } + + // Metadata field keys the reader already binds to typed prim state above; they are excluded + // from the ยง6.3 custom-metadata dictionary so a round trip re-emits them from that typed + // state, not twice. A list-op arc (prepend/append/delete references/payload/โ€ฆ and + // apiSchemas/variantSets) is excluded by its qualifier rather than by name. + private static readonly HashSet s_wellKnownPrimMeta = new HashSet( + new[] + { + "doc", "documentation", "kind", "active", "instanceable", + "apiSchemas", "references", "payload", "inherits", "specializes", + "variants", "variantSets", "typeName", "specifier", + }, StringComparer.Ordinal); + + /// + /// Populates with the ยง6.3 custom (non-well-known) metadata + /// authored in the prim's ( โ€ฆ ) block, so metadata-for-metadata (well-known + custom) + /// survives a round trip (ยง7.4). Each entry is a [type] key = value field; the last + /// word before = is the key, an optional leading type token is discarded, and a + /// leading prepend/append/delete qualifier marks a list-op arc that the + /// typed parsing above already handled, so it is skipped here. A nested { โ€ฆ } maps to + /// a nested (ยง6.3), supported to arbitrary depth. + /// + private static void ApplyCustomPrimMeta(UsdPrim prim, string block) + { + int pos = 0; + while (TryReadMetaEntry(block, ref pos, out string key, out object? value, out bool qualified)) + { + if (key.Length == 0 || qualified || s_wellKnownPrimMeta.Contains(key)) + { + continue; + } + prim.Metadata[key] = value; + } + } + + // Reads one '[qualifier] [type] key = value' entry from a metadata block or dictionary body. + // Returns false only when no further entry remains. When the chunk is not a well-formed + // 'key = value' (for example leftover list-op arc text), it still advances and returns true with an empty key so the caller skips it and keeps scanning. + private static bool TryReadMetaEntry( + string s, ref int pos, out string key, out object? value, out bool qualified) + { + key = string.Empty; + value = null; + qualified = false; + + SkipMetaSeparators(s, ref pos); + if (pos >= s.Length) + { + return false; + } + + var words = new List(); + var word = new StringBuilder(); + bool sawEquals = false; + while (pos < s.Length) + { + char c = s[pos]; + if (c == '=') + { + pos++; + sawEquals = true; + break; + } + if (c == '\n' || c == ',' || c == ';') + { + pos++; + break; + } + if (c == ' ' || c == '\t' || c == '\r') + { + if (word.Length > 0) + { + words.Add(word.ToString()); + word.Clear(); + } + pos++; + continue; + } + if (c == '{' || c == '(' || c == '<' || c == '@' || c == '"' || c == '\'') + { + // A value token before any '=' is leftover from a preceding entry (for example a + // bare '' after a list-op arc). Consume and ignore it so scanning advances. + ReadMetaValue(s, ref pos); + break; + } + word.Append(c); + pos++; + } + if (word.Length > 0) + { + words.Add(word.ToString()); + } + + if (!sawEquals || words.Count == 0) + { + return true; + } + + key = words[words.Count - 1]; + for (int i = 0; i < words.Count - 1; i++) + { + if (string.Equals(words[i], "prepend", StringComparison.Ordinal) + || string.Equals(words[i], "append", StringComparison.Ordinal) + || string.Equals(words[i], "delete", StringComparison.Ordinal)) + { + qualified = true; + } + } + + value = ReadMetaValue(s, ref pos); + return true; + } + + // Reads a single metadata value: a nested '{ โ€ฆ }' dictionary (parsed recursively into a + // Dictionary), or a scalar/tuple/array/asset/path token read up to the next + // depth-0 ',' or newline and parsed by ParseValue. Honours quoted and '@โ€ฆ@' spans. + private static object? ReadMetaValue(string s, ref int pos) + { + SkipInlineMetaWhitespace(s, ref pos); + if (pos >= s.Length) + { + return null; + } + if (s[pos] == '{') + { + int end = SkipBalanced(s, pos, '{', '}'); + int innerStart = pos + 1; + int innerLen = Math.Max(0, end - 1 - innerStart); + string inner = s.Substring(innerStart, innerLen); + pos = end; + var dict = new Dictionary(StringComparer.Ordinal); + int innerPos = 0; + while (TryReadMetaEntry(inner, ref innerPos, out string k, out object? v, out _)) + { + if (k.Length > 0) + { + dict[k] = v; + } + } + return dict; + } + + int valueStart = pos; + int depth = 0; + bool inQuote = false; + char quote = '\0'; + bool inAsset = false; + while (pos < s.Length) + { + char c = s[pos]; + if (inQuote) + { + if (c == '\\') + { + pos++; + } + else if (c == quote) + { + inQuote = false; + } + pos++; + continue; + } + if (inAsset) + { + if (c == '@') + { + inAsset = false; + } + pos++; + continue; + } + if (c == '"' || c == '\'') + { + inQuote = true; + quote = c; + pos++; + continue; + } + if (c == '@') + { + inAsset = true; + pos++; + continue; + } + if (c == '(' || c == '[' || c == '{') + { + depth++; + pos++; + continue; + } + if (c == ')' || c == ']' || c == '}') + { + if (depth > 0) + { + depth--; + } + pos++; + continue; + } + if (depth == 0 && (c == ',' || c == '\n')) + { + break; + } + pos++; + } + return ParseValue(s.Substring(valueStart, pos - valueStart).Trim()); + } + + private static void SkipMetaSeparators(string s, ref int pos) + { + while (pos < s.Length) + { + char c = s[pos]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ',' || c == ';') + { + pos++; + continue; + } + break; + } + } + + private static void SkipInlineMetaWhitespace(string s, ref int pos) + { + while (pos < s.Length && (s[pos] == ' ' || s[pos] == '\t' || s[pos] == '\r')) + { + pos++; + } + } + + private static UsdPrimKindEnum ParseKind(string kind) + { + return kind.ToLowerInvariant() switch + { + "model" => UsdPrimKindEnum.Model, + "group" => UsdPrimKindEnum.Group, + "assembly" => UsdPrimKindEnum.Assembly, + "component" => UsdPrimKindEnum.Component, + "subcomponent" => UsdPrimKindEnum.Subcomponent, + _ => UsdPrimKindEnum.Unspecified, + }; + } + + private static UsdListOpTypeEnum ParseListPosition(string position) + { + return position switch + { + "append" => UsdListOpTypeEnum.Append, + "delete" => UsdListOpTypeEnum.Delete, + _ => UsdListOpTypeEnum.Prepend, + }; + } + + // ----- scoped composition (example overlays) ----- + + private static void ApplyExampleOverlays(UsdStage stage, string sourcePath) + { + string baseName = Path.GetFileName(sourcePath).ToLowerInvariant(); + string directory = Path.GetDirectoryName(sourcePath) ?? string.Empty; + + if (string.Equals(baseName, "plant.usda", StringComparison.Ordinal)) + { + UsdPrim? p101 = stage.Find("/Plant/Pumps/P101"); + if (p101 != null) + { + p101.Composition.Add(MakeArc(UsdArcKindEnum.Reference, "pump.usda", "/Pump", UsdListOpTypeEnum.Append)); + p101.Composition.Add(MakeArc(UsdArcKindEnum.Instance, "pump.usda", "/Pump", UsdListOpTypeEnum.Append)); + } + UsdPrim? impeller = stage.Find("/Plant/Pumps/P101/Impeller"); + if (impeller != null) + { + foreach (UsdAttribute a in impeller.Attributes) + { + if (string.Equals(a.Name, "xformOp:rotateZ", StringComparison.Ordinal)) + { + a.Live = true; + a.Variability = UsdVariabilityEnum.Varying; + } + } + } + return; + } + + if (string.Equals(baseName, "cell.usda", StringComparison.Ordinal)) + { + string robotPath = Path.Combine(directory, "robot.usda"); + string toolPath = Path.Combine(directory, "tool.usda"); + + if (File.Exists(robotPath)) + { + UsdPrim robot = ParseFile(robotPath, "RobotAsset", applyExampleOverlays: false).RootPrims[0]; + foreach (string mountPath in new[] { "/Cell/Robots/R1", "/Cell/Robots/R2" }) + { + UsdPrim? mount = stage.Find(mountPath); + if (mount == null) + { + continue; + } + Merge(mount, robot); + mount.Kind = UsdPrimKindEnum.Component; + mount.Composition.Add(MakeArc(UsdArcKindEnum.Reference, "robot.usda", "/Robot", UsdListOpTypeEnum.Append)); + mount.Composition.Add(MakeArc(UsdArcKindEnum.Instance, "robot.usda", "/Robot", UsdListOpTypeEnum.Append)); + + UsdPrim? basePrim = stage.Find(mountPath + "/Base"); + basePrim?.ApiSchemas.Add(new UsdApiSchema("CollectionAPI") { ExpansionRule = "expandPrims" }); + + string baseRoot = mountPath + "/Base"; + foreach (UsdPrim prim in stage.AllPrims()) + { + if (prim.Path.StartsWith(baseRoot, StringComparison.Ordinal) && JointRegex().IsMatch(prim.Path)) + { + foreach (UsdAttribute a in prim.Attributes) + { + if (a.Name.StartsWith("xformOp:rotate", StringComparison.Ordinal)) + { + a.Live = true; + } + } + } + } + } + } + + if (File.Exists(toolPath)) + { + UsdPrim tool = ParseFile(toolPath, "ToolAsset", applyExampleOverlays: false).RootPrims[0]; + UsdPrim? flange = stage.Find("/Cell/Robots/R1/Base/J1/J2/J3/J4/J5/J6/Flange"); + if (flange != null) + { + string oldToolPath = tool.Path; + UsdPrim toolClone = ClonePrim(tool, "Tool"); + toolClone.Composition.Add(MakeArc(UsdArcKindEnum.Reference, "tool.usda", "/Gripper", UsdListOpTypeEnum.Append)); + flange.AddChild(toolClone); + RemapPaths(toolClone, oldToolPath, toolClone.Path); + } + } + } + } + + private static UsdCompositionArc MakeArc(UsdArcKindEnum kind, string asset, string primPath, UsdListOpTypeEnum position) + { + return new UsdCompositionArc(kind) + { + AssetPath = asset, + PrimPath = primPath, + ListPosition = position, + }; + } + + private static void Merge(UsdPrim destination, UsdPrim source) + { + foreach (UsdAttribute a in source.Attributes) + { + destination.Attributes.Add(CloneAttribute(a)); + } + foreach (UsdRelationship r in source.Relationships) + { + destination.Relationships.Add(CloneRelationship(r)); + } + foreach (UsdApiSchema api in source.ApiSchemas) + { + destination.ApiSchemas.Add(CloneApiSchema(api)); + } + foreach (UsdPrim child in source.Children) + { + destination.AddChild(ClonePrim(child, null)); + } + RemapPaths(destination, source.Path, destination.Path); + } + + private static UsdPrim ClonePrim(UsdPrim source, string? newName) + { + var clone = new UsdPrim(newName ?? source.Name, source.TypeName) + { + Specifier = source.Specifier, + Kind = source.Kind, + Active = source.Active, + Instanceable = source.Instanceable, + Documentation = source.Documentation, + }; + foreach (UsdAttribute a in source.Attributes) + { + clone.Attributes.Add(CloneAttribute(a)); + } + foreach (UsdRelationship r in source.Relationships) + { + clone.Relationships.Add(CloneRelationship(r)); + } + foreach (UsdCompositionArc arc in source.Composition) + { + clone.Composition.Add(CloneArc(arc)); + } + foreach (UsdApiSchema api in source.ApiSchemas) + { + clone.ApiSchemas.Add(CloneApiSchema(api)); + } + foreach (UsdVariantSet vs in source.VariantSets) + { + var clonedSet = new UsdVariantSet(vs.SetName, vs.Selection); + foreach (UsdPrim branch in vs.Variants) + { + clonedSet.Variants.Add(ClonePrim(branch, null)); + } + clone.VariantSets.Add(clonedSet); + } + foreach (KeyValuePair kv in source.Metadata) + { + clone.Metadata[kv.Key] = kv.Value; + } + foreach (UsdPrim child in source.Children) + { + clone.AddChild(ClonePrim(child, null)); + } + return clone; + } + + private static UsdAttribute CloneAttribute(UsdAttribute source) + { + var clone = new UsdAttribute(source.Name, source.TypeName) + { + Value = source.Value, + Variability = source.Variability, + Custom = source.Custom, + Interpolation = source.Interpolation, + Live = source.Live, + }; + foreach (string connection in source.Connections) + { + clone.Connections.Add(connection); + } + foreach (KeyValuePair sample in source.TimeSamples) + { + clone.TimeSamples[sample.Key] = sample.Value; + } + return clone; + } + + private static UsdRelationship CloneRelationship(UsdRelationship source) + { + var clone = new UsdRelationship(source.Name) { Custom = source.Custom }; + foreach (string target in source.Targets) + { + clone.Targets.Add(target); + } + return clone; + } + + private static UsdCompositionArc CloneArc(UsdCompositionArc source) + { + return new UsdCompositionArc(source.ArcKind) + { + AssetPath = source.AssetPath, + PrimPath = source.PrimPath, + ListPosition = source.ListPosition, + VariantSet = source.VariantSet, + VariantSelection = source.VariantSelection, + }; + } + + private static UsdApiSchema CloneApiSchema(UsdApiSchema source) + { + return new UsdApiSchema(source.SchemaName) { ExpansionRule = source.ExpansionRule }; + } + + private static void RemapPaths(UsdPrim prim, string oldPrefix, string newPrefix) + { + foreach (UsdAttribute a in prim.Attributes) + { + if (a.Connections.Count > 0) + { + var remapped = a.Connections.Select(c => Remap(c, oldPrefix, newPrefix)).ToList(); + a.Connections.Clear(); + foreach (string c in remapped) + { + a.Connections.Add(c); + } + } + } + foreach (UsdRelationship r in prim.Relationships) + { + var remapped = r.Targets.Select(t => Remap(t, oldPrefix, newPrefix)).ToList(); + r.Targets.Clear(); + foreach (string t in remapped) + { + r.Targets.Add(t); + } + } + foreach (UsdPrim child in prim.Children) + { + RemapPaths(child, oldPrefix, newPrefix); + } + } + + private static string Remap(string path, string oldPrefix, string newPrefix) + { + if (string.Equals(path, oldPrefix, StringComparison.Ordinal)) + { + return newPrefix; + } + if (path.StartsWith(oldPrefix + "/", StringComparison.Ordinal) + || path.StartsWith(oldPrefix + ".", StringComparison.Ordinal)) + { + var remapped = new StringBuilder(newPrefix.Length + (path.Length - oldPrefix.Length)); + remapped.Append(newPrefix); + remapped.Append(path, oldPrefix.Length, path.Length - oldPrefix.Length); + return remapped.ToString(); + } + return path; + } + + // Ordinal single-character containment without String.Contains(char, StringComparison), + // which is unavailable on the down-level library targets (net472/net48). + private static bool ContainsChar(string value, char c) + { + for (int i = 0; i < value.Length; i++) + { + if (value[i] == c) + { + return true; + } + } + return false; + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs new file mode 100644 index 0000000000..9303fc784d --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Conversion/UsdaWriter.cs @@ -0,0 +1,827 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Conversion +{ + /// + /// Serialises a composed back to a single flattened .usda + /// layer, per draft OPC UA โ€” OpenUSD Scene Materialization ยง7.2 (address space โ†’ export). + /// + /// + /// This is the C# port of the specification reference converter's write_usda, + /// _usd_val writer (see metaverse-specs/extras/openusd-scene/tools/scene_common.py). + /// The emitted layer is provenance-aware: a prim's recorded composition arcs are re-emitted as + /// references/payload/inherits/specializes, and an + /// arc is re-expressed as instanceable = true. The + /// output re-parses (with example overlays disabled) to a composed-scene-equivalent stage under + /// the ยง7.4 round-trip contract. + /// + public static class UsdaWriter + { + private static readonly HashSet s_tupleGroupTypes = new HashSet( + new[] { "color3f", "float3", "double3", "int3" }, System.StringComparer.Ordinal); + + /// + /// Serialises a stage to a .usda layer string. + /// + /// The composed stage to serialise. + /// The .usda text, newline-terminated with \n line endings. + public static string Write(UsdStage stage) + { + if (stage == null) + { + throw new System.ArgumentNullException(nameof(stage)); + } + + var lines = new List + { + "#usda 1.0", + "(", + " defaultPrim = \"" + stage.DefaultPrim + "\"", + " metersPerUnit = " + FormatDouble(stage.MetersPerUnit), + " upAxis = \"" + stage.UpAxis + "\"", + ")", + string.Empty, + }; + + foreach (UsdPrim root in stage.RootPrims) + { + EmitPrim(root, 0, lines); + } + + var sb = new StringBuilder(); + foreach (string line in lines) + { + sb.Append(line).Append('\n'); + } + return sb.ToString(); + } + + /// + /// Serialises a stage and writes it to a file using \n line endings and UTF-8. + /// + /// The composed stage to serialise. + /// The destination file path. + public static void WriteToFile(UsdStage stage, string path) + { + File.WriteAllText(path, Write(stage), new UTF8Encoding(false)); + } + + private static void EmitPrim(UsdPrim prim, int indent, List lines) + { + string spec = prim.Specifier switch + { + UsdSpecifierEnum.Over => "over", + UsdSpecifierEnum.Class => "class", + _ => "def", + }; + string typ = prim.TypeName.Length > 0 ? prim.TypeName + " " : string.Empty; + string pad = new string(' ', 4 * indent); + + var arcLines = new List(); + foreach (UsdCompositionArc arc in prim.Composition) + { + if (arc.ArcKind == UsdArcKindEnum.Instance) + { + continue; + } + string? kw = arc.ArcKind switch + { + UsdArcKindEnum.Reference => "references", + UsdArcKindEnum.Payload => "payload", + UsdArcKindEnum.Inherit => "inherits", + UsdArcKindEnum.Specialize => "specializes", + _ => null, + }; + if (kw == null) + { + continue; + } + string reference = arc.PrimPath.Length > 0 + ? "@" + EscapeAssetPath(arc.AssetPath) + "@<" + + EscapeAssetPath(arc.PrimPath) + ">" + : "@" + EscapeAssetPath(arc.AssetPath) + "@"; + arcLines.Add(pad + " " + ListPositionKeyword(arc.ListPosition) + " " + kw + " = " + reference); + } + + bool instanceable = prim.Instanceable; + foreach (UsdCompositionArc arc in prim.Composition) + { + if (arc.ArcKind == UsdArcKindEnum.Instance) + { + instanceable = true; + break; + } + } + + var varLines = new List(); + foreach (UsdVariantSet vs in prim.VariantSets) + { + if (vs.Selection.Length > 0) + { + varLines.Add(pad + " variants = { string " + EscapeQuoted(vs.SetName) + + " = \"" + EscapeQuoted(vs.Selection) + "\" }"); + } + varLines.Add(pad + " prepend variantSets = \"" + EscapeQuoted(vs.SetName) + "\""); + } + + bool hasDoc = prim.Documentation.Length > 0; + bool hasMeta = prim.Kind != UsdPrimKindEnum.Unspecified || prim.ApiSchemas.Count > 0 + || arcLines.Count > 0 || instanceable || varLines.Count > 0 || hasDoc + || prim.Metadata.Count > 0; + + lines.Add(pad + spec + " " + typ + "\"" + EscapeQuoted(prim.Name) + "\"" + (hasMeta ? " (" : string.Empty)); + if (hasMeta) + { + if (hasDoc) + { + lines.Add(pad + " doc = \"\"\"" + + EscapeTripleQuoted(prim.Documentation) + "\"\"\""); + } + if (prim.Kind != UsdPrimKindEnum.Unspecified) + { + lines.Add(pad + " kind = \"" + prim.Kind.ToString().ToLowerInvariant() + "\""); + } + if (prim.ApiSchemas.Count > 0) + { + var names = new List(); + foreach (UsdApiSchema api in prim.ApiSchemas) + { + names.Add("\"" + api.SchemaName + "\""); + } + lines.Add(pad + " prepend apiSchemas = [" + string.Join(", ", names) + "]"); + } + lines.AddRange(arcLines); + if (instanceable) + { + lines.Add(pad + " instanceable = true"); + } + lines.AddRange(varLines); + foreach (KeyValuePair entry in prim.Metadata) + { + EmitMetaEntry(entry.Key, entry.Value, pad + " ", lines, typed: false); + } + lines.Add(pad + ")"); + } + lines.Add(pad + "{"); + EmitPrimBodyContent(prim, indent, lines); + lines.Add(pad + "}"); + } + + /// + /// Emits the body of a prim (attributes, relationships, variant-set branch blocks and child + /// prims) at one indent level deeper than . Reused for variant + /// branch bodies so a branch round-trips with the same fidelity as a top-level prim (ยง5.6). + /// + private static void EmitPrimBodyContent(UsdPrim prim, int indent, List lines) + { + string pad = new string(' ', 4 * indent); + string body = pad + " "; + + foreach (UsdAttribute attr in prim.Attributes) + { + string pre = (attr.Custom ? "custom " : string.Empty) + + (attr.Variability == UsdVariabilityEnum.Uniform ? "uniform " : string.Empty); + string v = UsdVal(attr.Value, attr.TypeName); + bool hasSamples = attr.TimeSamples.Count > 0; + + // A default value, time samples and connections may be co-authored on the same + // attribute, and USD permits several connection targets, so emit the value (when + // present), the '.timeSamples' block and every connection rather than only the + // first (ยง5.4, ยง7.1). An attribute with none of these is still declared by name. + if (v.Length > 0) + { + lines.Add(body + pre + attr.TypeName + " " + attr.Name + " = " + v); + } + else if (attr.Connections.Count == 0 && !hasSamples) + { + lines.Add(body + pre + attr.TypeName + " " + attr.Name); + } + + if (hasSamples) + { + EmitTimeSamples(attr, body, pre, lines); + } + + if (attr.Connections.Count > 0) + { + lines.Add(body + pre + attr.TypeName + " " + attr.Name + + ".connect = " + FormatConnectionTargets(attr.Connections)); + } + } + + foreach (UsdRelationship rel in prim.Relationships) + { + if (rel.Targets.Count == 1) + { + lines.Add(body + "rel " + rel.Name + " = <" + rel.Targets[0] + ">"); + } + else if (rel.Targets.Count > 0) + { + var targets = new List(); + foreach (string t in rel.Targets) + { + targets.Add("<" + t + ">"); + } + lines.Add(body + "rel " + rel.Name + " = [" + string.Join(", ", targets) + "]"); + } + else + { + lines.Add(body + "rel " + rel.Name + " = []"); + } + } + + // Re-emit every authored variant branch under its set so the full '' structure + // round-trips (ยง5.6). Selection-only sets (no captured branches) carry no block here; + // their selection is already emitted in the prim metadata header. + foreach (UsdVariantSet vs in prim.VariantSets) + { + if (vs.Variants.Count == 0) + { + continue; + } + lines.Add(body + "variantSet \"" + vs.SetName + "\" = {"); + foreach (UsdPrim branch in vs.Variants) + { + lines.Add(body + " \"" + branch.Name + "\" {"); + EmitPrimBodyContent(branch, indent + 2, lines); + lines.Add(body + " }"); + } + lines.Add(body + "}"); + } + + foreach (UsdPrim child in prim.Children) + { + EmitPrim(child, indent + 1, lines); + } + } + + private static string ListPositionKeyword(UsdListOpTypeEnum position) + { + return position switch + { + UsdListOpTypeEnum.Append => "append", + UsdListOpTypeEnum.Delete => "delete", + _ => "prepend", + }; + } + + /// + /// Renders an attribute's connection targets as authored .usda text. A single + /// target is written as a bare path reference; several are written as a path-reference + /// list, so all authored connections survive rather than only the first (ยง5.4). + /// + private static string FormatConnectionTargets(IList connections) + { + if (connections.Count == 1) + { + return "<" + connections[0] + ">"; + } + var targets = new List(connections.Count); + foreach (string target in connections) + { + targets.Add("<" + target + ">"); + } + return "[" + string.Join(", ", targets) + "]"; + } + + /// + /// Emits an attribute's authored time samples as a .timeSamples = { โ€ฆ } block + /// (ยง7.1, ยง9). Each sample renders as timeCode: value using the same value renderer + /// as the default so the two cannot drift, ordered by ascending time code (USD's composed + /// sample order), so the block re-parses to the identical sample map. + /// + private static void EmitTimeSamples(UsdAttribute attr, string attrPad, string pre, List lines) + { + lines.Add(attrPad + pre + attr.TypeName + " " + attr.Name + ".timeSamples = {"); + foreach (KeyValuePair sample in attr.TimeSamples) + { + lines.Add(attrPad + " " + FormatTimeCode(sample.Key) + ": " + + UsdVal(sample.Value, attr.TypeName) + ","); + } + lines.Add(attrPad + "}"); + } + + /// + /// Formats a USD time code: an integer-valued code without a decimal point (0, + /// 24, -6) as USD authors it, a fractional code in shortest round-trippable + /// form (0.5, -2.25). Always invariant culture. + /// + private static string FormatTimeCode(double t) + { + if (!double.IsNaN(t) && !double.IsInfinity(t) + && t == System.Math.Floor(t) && System.Math.Abs(t) < 1e15) + { + return ((long)t).ToString(CultureInfo.InvariantCulture); + } + return t.ToString("R", CultureInfo.InvariantCulture); + } + + /// + /// Renders a scalar string attribute value. A USD asset path is authored with + /// @โ€ฆ@ delimiters (ยง6.2 asset โ†’ UsdAssetPath), symmetric with the + /// reader that unwraps @โ€ฆ@; a recognised string-like scalar or a recognised array + /// type is quoted; an unmapped (opaque) type carries its already-rendered value verbatim + /// so a structured opaque value survives the round trip (ยง8.4). + /// + private static string RenderStringValue(string s, string typeName) + { + if (string.Equals(typeName, "asset", System.StringComparison.Ordinal)) + { + return "@" + EscapeAssetPath(s) + "@"; + } + bool quoted = + (typeName is "token" or "string" + || typeName.EndsWith("[]", System.StringComparison.Ordinal)) + && UsdValueTypeMap.IsKnown(typeName); + return quoted ? "\"" + EscapeQuoted(s) + "\"" : s; + } + + /// + /// Escapes a value so it cannot terminate the quoted .usda + /// literal it is authored into. + /// + /// + /// Attribute values are remotely writable once materialised - a + /// Live attribute is created with CurrentReadOrWrite access - and + /// the exporter reads them back verbatim. Without escaping, a value + /// containing a quote and a newline closes the literal and authors + /// arbitrary layer metadata, including references, payload + /// and subLayers arcs that any renderer opening the exported file + /// would then resolve. This mirrors UsdFileSink.EscapeToken and is + /// symmetric with UsdaReader.TryParseQuoted, which already + /// consumes these escapes. + /// + /// The raw value. + /// The escaped value. + private static string EscapeQuoted(string s) + { + var sb = new StringBuilder(s.Length); + foreach (char c in s) + { + switch (c) + { + case '\\': + sb.Append("\\\\"); + break; + case '"': + sb.Append("\\\""); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + sb.Append(c); + break; + } + } + return sb.Length == s.Length ? s : sb.ToString(); + } + + /// + /// Strips the characters that would terminate an @โ€ฆ@ asset + /// reference or its optional <primPath> suffix. + /// + /// + /// USD has no escape sequence inside an asset reference, so an unsafe + /// character cannot be encoded - it is removed, which keeps the emitted + /// layer well formed rather than letting the value inject a new arc. + /// + /// The raw asset path or prim path. + /// The sanitised value. + private static string EscapeAssetPath(string s) + { + var sb = new StringBuilder(s.Length); + foreach (char c in s) + { + if (!IsAssetPathUnsafe(c)) + { + sb.Append(c); + } + } + return sb.Length == s.Length ? s : sb.ToString(); + } + + private static bool IsAssetPathUnsafe(char c) + { + return c is '@' or '<' or '>' or '"' or '\n' or '\r'; + } + + /// + /// Neutralises a value authored inside a """โ€ฆ""" literal. + /// + /// + /// A triple-quoted USD literal has no escape sequence, so the only way + /// to keep a value from terminating it is to make a """ run + /// impossible; quote runs are collapsed to a single quote. Line breaks + /// are folded to spaces as well, so a payload cannot occupy a line of + /// its own and be mistaken for a directive by a line-oriented reader. + /// + /// The raw documentation text. + /// The sanitised text. + private static string EscapeTripleQuoted(string s) + { + var sb = new StringBuilder(s.Length); + bool previousWasQuote = false; + bool changed = false; + foreach (char c in s) + { + if (c == '"') + { + if (previousWasQuote) + { + changed = true; + } + else + { + sb.Append(c); + } + previousWasQuote = true; + continue; + } + previousWasQuote = false; + if (c is '\n' or '\r') + { + sb.Append(' '); + changed = true; + continue; + } + sb.Append(c); + } + return changed ? sb.ToString() : s; + } + + /// + /// Renders a parsed USD value to its authored .usda text so an unmapped (opaque) + /// value can be carried through materialization and re-emitted verbatim on export (ยง8.4). + /// Reuses the same scalar/tuple/array rendering as and + /// so the carried text and a direct emission cannot drift. Returns + /// false for a value this writer cannot render faithfully, so the caller can fail + /// closed rather than publish a CLR type name. + /// + /// The parsed USD value. + /// The rendered .usda text when the method returns true. + /// true when the value could be rendered. + internal static bool TryRenderOpaqueValue(object? value, out string text) + { + text = string.Empty; + switch (value) + { + case null: + return true; + case bool b: + text = b ? "true" : "false"; + return true; + case string s: + text = s; + return true; + case double: + case float: + case long: + case int: + text = PyStr(value); + return true; + } + if (value is object?[] tuple) + { + return TryRenderOpaqueSequence(tuple, asTuple: true, out text); + } + if (value is List list) + { + return TryRenderOpaqueSequence(list, asTuple: false, out text); + } + return false; + } + + private static bool TryRenderOpaqueSequence( + IReadOnlyList items, bool asTuple, out string text) + { + text = string.Empty; + var parts = new List(items.Count); + foreach (object? item in items) + { + if (!TryRenderOpaqueValue(item, out string part)) + { + return false; + } + // A string leaf inside a USD tuple/array is authored quoted. + parts.Add(item is string ? "\"" + part + "\"" : part); + } + string joined = string.Join(", ", parts); + text = asTuple ? "(" + joined + ")" : "[" + joined + "]"; + return true; + } + + /// + /// Emits one ยง6.3 metadata entry (a scalar/tuple/array field or a nested dictionary) into + /// the prim's ( โ€ฆ ) block, symmetric with UsdaReader.ApplyCustomPrimMeta. A + /// nested is emitted as a { โ€ฆ } block whose + /// entries are one indent deeper. A dictionary entry carries a USD value-type token + /// (); a top-level prim metadata field does not. + /// + private static void EmitMetaEntry( + string key, object? value, string pad, List lines, bool typed) + { + if (value is IDictionary dict) + { + lines.Add(pad + (typed ? "dictionary " : string.Empty) + key + " = {"); + foreach (KeyValuePair entry in dict) + { + EmitMetaEntry(entry.Key, entry.Value, pad + " ", lines, typed: true); + } + lines.Add(pad + "}"); + return; + } + bool isSequence = value is object?[] || value is List; + string prefix = typed && !isSequence ? MetaTypeToken(value) + " " : string.Empty; + lines.Add(pad + prefix + key + " = " + RenderMetaScalar(value)); + } + + /// + /// Returns the USD dictionary value-type token for a scalar/dictionary metadata value. The + /// token is only cosmetic on a round trip (the reader discards it), so an integer maps to + /// int and a floating point value to double. + /// + private static string MetaTypeToken(object? value) + { + switch (value) + { + case bool _: + return "bool"; + case long _: + case int _: + return "int"; + case double _: + case float _: + return "double"; + case IDictionary _: + return "dictionary"; + default: + return "string"; + } + } + + /// + /// Renders a ยง6.3 metadata scalar (or a tuple/array value) to its authored text. A string is + /// double-quoted (USD's string form), a bool uses true/false, numbers use the + /// same invariant formatting as attribute values, and a tuple/array falls back to + /// . + /// + private static string RenderMetaScalar(object? value) + { + switch (value) + { + case null: + return "\"\""; + case string s: + return "\"" + s + "\""; + case bool b: + return b ? "true" : "false"; + case double d: + return FormatDouble(d); + case float f: + return FormatDouble(f); + case long l: + return l.ToString(CultureInfo.InvariantCulture); + case int i: + return i.ToString(CultureInfo.InvariantCulture); + } + return PyStr(value); + } + + /// + /// Renders an attribute value as authored .usda text (port of _usd_val). + /// + private static string UsdVal(object? value, string typeName) + { + switch (value) + { + case null: + return string.Empty; + case string s: + return RenderStringValue(s, typeName); + case bool b: + return b ? "true" : "false"; + } + + // Shape contract: whether a value is authored as a USD array "[...]" or as a USD tuple + // "(...)" is decided by the ATTRIBUTE TYPE, never by the CLR container that carries the + // value. An array-typed attribute (TypeName ending in "[]") always emits "[...]"; a + // fixed-size math scalar (double3, color3f, matrix4d, ...) emits a single parenthesised + // tuple "(...)" through PyStr below. Keying on the type name โ€” rather than on + // "value is List" โ€” keeps this contract intact no matter which container the + // coercion layer hands back: UsdValueCoercion.Decoerce returns object?[] for both a + // flat array and an array-of-tuples, while the reader returns List; both must + // round-trip to the same "[...]" text here (regression guard for the H-1 defect where + // every exported array fell through to PyStr and was corrupted into a "(...)" tuple). + if (typeName.EndsWith("[]", System.StringComparison.Ordinal) + && TryGetSequence(value, out IReadOnlyList items)) + { + string baseType = typeName.Substring(0, typeName.Length - 2); + return RenderArray(items, baseType); + } + + return PyStr(value); + } + + /// + /// Exposes a value as a sequence when it is one of the two containers the document model + /// and coercion layer use for a USD array: object?[] (returned by + /// ) or List<object?> (returned by the + /// reader). Anything else is not a sequence and is rendered as a scalar/tuple by the caller. + /// + private static bool TryGetSequence(object? value, out IReadOnlyList items) + { + switch (value) + { + case object?[] array: + items = array; + return true; + case List list: + items = list; + return true; + } + items = System.Array.Empty(); + return false; + } + + /// + /// Renders an array-typed value as [โ€ฆ]. A tuple-group base type + /// (color3f/float3/double3/int3) handed back as a flat run of + /// scalar components is regrouped into per-tuple rows so a color3f[] still emits + /// [(r, g, b)]; when the elements are already grouped rows (each element is itself a + /// tuple) they render one parenthesised tuple per element instead. Every string element of + /// an asset[] is authored with @โ€ฆ@ delimiters (ยง6.2); any other string element + /// uses USD's double-quote form, never PyStr's single quotes. + /// + private static string RenderArray(IReadOnlyList items, string baseType) + { + bool assetArray = string.Equals(baseType, "asset", System.StringComparison.Ordinal); + + if (s_tupleGroupTypes.Contains(baseType) + && items.Count > 0 + && items.Count % 3 == 0 + && !AnyElementIsSequence(items)) + { + var groups = new List(items.Count / 3); + for (int i = 0; i < items.Count; i += 3) + { + var g = new List(3); + for (int j = i; j < i + 3; j++) + { + g.Add(PyStr(items[j])); + } + groups.Add("(" + string.Join(", ", g) + ")"); + } + return "[" + string.Join(", ", groups) + "]"; + } + + var elems = new List(items.Count); + foreach (object? x in items) + { + elems.Add(RenderArrayElement(x, assetArray)); + } + return "[" + string.Join(", ", elems) + "]"; + } + + /// + /// Renders one element of a USD array: an asset[] element with @โ€ฆ@ delimiters, + /// any other string element double-quoted (USD's string/token form), and a grouped tuple row + /// or scalar through (which parenthesises a tuple). + /// + private static string RenderArrayElement(object? x, bool assetArray) + { + if (x is string s) + { + return assetArray ? "@" + s + "@" : "\"" + s + "\""; + } + return PyStr(x); + } + + private static bool AnyElementIsSequence(IReadOnlyList items) + { + foreach (object? x in items) + { + if (x is object?[] || x is List) + { + return true; + } + } + return false; + } + + /// + /// Renders a scalar or tuple element with Python str() semantics so the output + /// re-parses identically (integers plain, floats keep a decimal point, tuples parenthesised). + /// + private static string PyStr(object? x) + { + switch (x) + { + case null: + return "None"; + case bool b: + return b ? "True" : "False"; + case string s: + return "'" + s + "'"; + case double d: + return FormatDouble(d); + case float f: + return FormatDouble(f); + case long l: + return l.ToString(CultureInfo.InvariantCulture); + case int i: + return i.ToString(CultureInfo.InvariantCulture); + } + + if (x is object?[] tuple) + { + var parts = new List(); + foreach (object? e in tuple) + { + parts.Add(PyStr(e)); + } + return "(" + string.Join(", ", parts) + ")"; + } + if (x is List generic) + { + var parts = new List(); + foreach (object? e in generic) + { + parts.Add(PyStr(e)); + } + return "[" + string.Join(", ", parts) + "]"; + } + return System.Convert.ToString(x, CultureInfo.InvariantCulture) ?? string.Empty; + } + + /// + /// Formats a double with Python str(float) semantics: shortest round-trippable form, + /// but always with a trailing .0 for integer-valued floats. + /// + private static string FormatDouble(double d) + { + string s = d.ToString("R", CultureInfo.InvariantCulture); + if (!double.IsNaN(d) && !double.IsInfinity(d) + && !ContainsChar(s, '.') + && !ContainsChar(s, 'e') + && !ContainsChar(s, 'E')) + { + s += ".0"; + } + return s; + } + + // Ordinal single-character containment without String.Contains(char, StringComparison), + // which is unavailable on the down-level library targets (net472/net48). + private static bool ContainsChar(string value, char c) + { + for (int i = 0; i < value.Length; i++) + { + if (value[i] == c) + { + return true; + } + } + return false; + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Model/Opc.Ua.OpenUsdScene.NodeSet2.csv b/src/Opc.Ua.OpenUsdScene/Model/Opc.Ua.OpenUsdScene.NodeSet2.csv new file mode 100644 index 0000000000..9a411f2802 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Model/Opc.Ua.OpenUsdScene.NodeSet2.csv @@ -0,0 +1,128 @@ +UsdSpecifierEnum,3001,DataType +UsdSpecifierEnum_EnumStrings,3901,Variable +UsdVariabilityEnum,3002,DataType +UsdVariabilityEnum_EnumStrings,3902,Variable +UsdPrimKindEnum,3003,DataType +UsdPrimKindEnum_EnumStrings,3903,Variable +UsdListOpTypeEnum,3004,DataType +UsdListOpTypeEnum_EnumStrings,3904,Variable +UsdArcKindEnum,3005,DataType +UsdArcKindEnum_EnumStrings,3905,Variable +UsdToken,3006,DataType +UsdAssetPath,3007,DataType +UsdTimeCode,3008,DataType +UsdColor3f,3013,DataType +UsdNormal3f,3014,DataType +UsdPoint3f,3015,DataType +UsdVector3f,3016,DataType +UsdTexCoord2f,3017,DataType +UsdQuatf,3018,DataType +UsdQuatd,3019,DataType +UsdMatrix4d,3020,DataType +UsdLayerOffset,3010,DataType +UsdReferenceSpec,3011,DataType +UsdVariantSelection,3012,DataType +UsdRelationshipTarget,4001,ReferenceType +UsdConnection,4002,ReferenceType +UsdAttributeType,2001,VariableType +UsdAttributeType_UsdTypeName,6001,Variable +UsdAttributeType_Variability,6002,Variable +UsdAttributeType_Custom,6003,Variable +UsdAttributeType_Namespace,6004,Variable +UsdAttributeType_Interpolation,6005,Variable +UsdStageType,1001,ObjectType +UsdStageType_DefaultPrim,6006,Variable +UsdStageType_UpAxis,6007,Variable +UsdStageType_MetersPerUnit,6008,Variable +UsdStageType_KilogramsPerUnit,6009,Variable +UsdStageType_TimeCodesPerSecond,6010,Variable +UsdStageType_StartTimeCode,6011,Variable +UsdStageType_EndTimeCode,6012,Variable +UsdStageType_RootLayerIdentifier,6013,Variable +UsdStageType_Documentation,6014,Variable +UsdPrimType,1002,ObjectType +UsdPrimType_Specifier,6015,Variable +UsdPrimType_TypeName,6016,Variable +UsdPrimType_Kind,6017,Variable +UsdPrimType_Active,6018,Variable +UsdPrimType_Instanceable,6019,Variable +UsdPrimType_Documentation,6020,Variable +UsdStageType_UsdPrim,6021,Object +UsdPrimType_UsdPrim,6022,Object +UsdPrimType_UsdAttribute,6023,Variable +UsdPrimType_UsdRelationship,6024,Object +UsdPrimType_AppliedSchemas,6025,Object +UsdPrimType_Composition,6026,Object +UsdPrimType_VariantSets,6027,Object +UsdPrimType_Metadata,6028,Object +UsdTypedType,1003,ObjectType +UsdGeomImageableType,1004,ObjectType +UsdGeomImageableType_Visibility,6029,Variable +UsdGeomImageableType_Purpose,6030,Variable +UsdGeomXformableType,1005,ObjectType +UsdGeomXformableType_XformOpOrder,6031,Variable +UsdGeomXformType,1006,ObjectType +UsdGeomScopeType,1007,ObjectType +UsdGeomGprimType,1008,ObjectType +UsdGeomGprimType_DisplayColor,6032,Variable +UsdGeomGprimType_DisplayOpacity,6033,Variable +UsdGeomGprimType_DoubleSided,6034,Variable +UsdGeomMeshType,1009,ObjectType +UsdGeomMeshType_Points,6035,Variable +UsdGeomMeshType_FaceVertexCounts,6036,Variable +UsdGeomMeshType_FaceVertexIndices,6037,Variable +UsdGeomCylinderType,1010,ObjectType +UsdGeomCylinderType_Height,6038,Variable +UsdGeomCylinderType_Radius,6039,Variable +UsdGeomCylinderType_Axis,6040,Variable +UsdGeomSphereType,1011,ObjectType +UsdGeomSphereType_Radius,6041,Variable +UsdGeomCubeType,1012,ObjectType +UsdGeomCubeType_Size,6042,Variable +UsdGeomConeType,1013,ObjectType +UsdGeomConeType_Height,6043,Variable +UsdGeomConeType_Radius,6044,Variable +UsdGeomConeType_Axis,6045,Variable +UsdGeomCapsuleType,1014,ObjectType +UsdGeomCapsuleType_Height,6046,Variable +UsdGeomCapsuleType_Radius,6047,Variable +UsdGeomCapsuleType_Axis,6048,Variable +UsdShadeMaterialType,1015,ObjectType +UsdShadeShaderType,1016,ObjectType +UsdShadeShaderType_Info_Id,6049,Variable +UsdRelationshipType,1017,ObjectType +UsdRelationshipType_Custom,6050,Variable +UsdRelationshipType_Targets,6051,Variable +UsdRelationshipType_TargetPaths,6052,Variable +UsdVariantSetType,1018,ObjectType +UsdVariantSetType_SetName,6053,Variable +UsdVariantSetType_Selection,6054,Variable +UsdVariantSetType_Variant,6055,Object +UsdCompositionArcType,1019,ObjectType +UsdCompositionArcType_ArcKind,6056,Variable +UsdCompositionArcType_AssetPath,6057,Variable +UsdCompositionArcType_PrimPath,6058,Variable +UsdCompositionArcType_ListPosition,6059,Variable +UsdCompositionArcType_VariantSet,6060,Variable +UsdCompositionArcType_VariantSelection,6061,Variable +UsdApiSchemaType,1020,ObjectType +UsdApiSchemaType_SchemaName,6062,Variable +UsdCollectionAPIType,1021,ObjectType +UsdCollectionAPIType_ExpansionRule,6063,Variable +UsdCollectionAPIType_IncludesTargets,6064,Variable +UsdCollectionAPIType_ExcludesTargets,6065,Variable +UsdGeoreferenceApiType,1022,ObjectType +UsdGeoreferenceApiType_Latitude,6066,Variable +UsdGeoreferenceApiType_Longitude,6067,Variable +UsdGeoreferenceApiType_Height,6068,Variable +UsdGeoreferenceApiType_EpsgCode,6069,Variable +UsdGeoreferenceApiType_TangentPlane,6070,Variable +UsdGlobeAnchorApiType,1023,ObjectType +UsdGlobeAnchorApiType_Latitude,6071,Variable +UsdGlobeAnchorApiType_Longitude,6072,Variable +UsdGlobeAnchorApiType_Height,6073,Variable +UsdPrimType_AppliedSchemas_UsdApiSchema,6074,Object +UsdPrimType_Composition_UsdCompositionArc,6075,Object +UsdPrimType_VariantSets_UsdVariantSet,6076,Object +UsdPrimType_Metadata_Metadata,6077,Variable +UsdAttributeType_ConnectionPaths,6078,Variable diff --git a/src/Opc.Ua.OpenUsdScene/Model/Opc.Ua.OpenUsdScene.NodeSet2.xml b/src/Opc.Ua.OpenUsdScene/Model/Opc.Ua.OpenUsdScene.NodeSet2.xml new file mode 100644 index 0000000000..13555434f8 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Model/Opc.Ua.OpenUsdScene.NodeSet2.xml @@ -0,0 +1,1241 @@ + + + + + http://opcfoundation.org/UA/OpenUSD/Scene/ + + + + + + + + i=1 + i=2 + i=6 + i=7 + i=8 + i=10 + i=11 + i=12 + i=15 + i=17 + i=20 + i=21 + i=290 + i=24 + i=47 + i=46 + i=45 + i=35 + i=40 + i=37 + i=17604 + i=44 + i=32 + i=17603 + i=78 + i=80 + i=11508 + + + UsdSpecifierEnum + USD prim specifier kind. + OpenUSD Scene DataTypes + + i=29 + ns=1;i=3901 + + + + + EnumStrings + + i=78 + i=68 + ns=1;i=3001 + + DefOverClass + + + UsdVariabilityEnum + USD attribute variability. + OpenUSD Scene DataTypes + + i=29 + ns=1;i=3902 + + + + + EnumStrings + + i=78 + i=68 + ns=1;i=3002 + + VaryingUniform + + + UsdPrimKindEnum + USD model kind metadata. + OpenUSD Scene DataTypes + + i=29 + ns=1;i=3903 + + + + + EnumStrings + + i=78 + i=68 + ns=1;i=3003 + + UnspecifiedModelGroupAssemblyComponentSubcomponent + + + UsdListOpTypeEnum + USD list operation position/type. + OpenUSD Scene DataTypes + + i=29 + ns=1;i=3904 + + + + + EnumStrings + + i=78 + i=68 + ns=1;i=3004 + + ExplicitPrependAppendDeleteOrdered + + + UsdArcKindEnum + USD composition arc kind. + OpenUSD Scene DataTypes + + i=29 + ns=1;i=3905 + + + + + EnumStrings + + i=78 + i=68 + ns=1;i=3005 + + ReferencePayloadInheritSpecializeVariantSetSublayerInstance + + + UsdToken + USD token value represented as a UA String subtype. + OpenUSD Scene DataTypes + + i=12 + + + + UsdAssetPath + USD asset path represented as a UA String subtype. + OpenUSD Scene DataTypes + + i=12 + + + + UsdTimeCode + USD time code represented as a UA Double subtype. + OpenUSD Scene DataTypes + + i=11 + + + + UsdColor3f + USD color3f value (RGB float triple); UA Float subtype carried as a 3-element Float array. + OpenUSD Scene DataTypes + + i=10 + + + + UsdNormal3f + USD normal3f value (float triple); UA Float subtype carried as a 3-element Float array. + OpenUSD Scene DataTypes + + i=10 + + + + UsdPoint3f + USD point3f value (float triple); UA Float subtype carried as a 3-element Float array. + OpenUSD Scene DataTypes + + i=10 + + + + UsdVector3f + USD vector3f value (float triple); UA Float subtype carried as a 3-element Float array. + OpenUSD Scene DataTypes + + i=10 + + + + UsdTexCoord2f + USD texCoord2f value (float pair); UA Float subtype carried as a 2-element Float array. + OpenUSD Scene DataTypes + + i=10 + + + + UsdQuatf + USD quatf value (float quaternion); UA Float subtype carried as a 4-element Float array. + OpenUSD Scene DataTypes + + i=10 + + + + UsdQuatd + USD quatd value (double quaternion); UA Double subtype carried as a 4-element Double array. + OpenUSD Scene DataTypes + + i=11 + + + + UsdMatrix4d + USD matrix4d value (row-major); UA Double subtype carried as a 16-element Double array. + OpenUSD Scene DataTypes + + i=11 + + + + UsdLayerOffset + USD layer offset and scale. + OpenUSD Scene DataTypes + + i=22 + + + + + UsdReferenceSpec + USD reference specification. + OpenUSD Scene DataTypes + + i=22 + + + + + UsdVariantSelection + USD variant set selection. + OpenUSD Scene DataTypes + + i=22 + + + + + UsdRelationshipTarget + UsdRelationshipTargetOf + Browsable relationship edge from a prim or relationship to its target prim. + OpenUSD Scene ReferenceTypes + + i=32 + + + + UsdConnection + UsdConnectionOf + Browsable USD attribute-connection edge. + OpenUSD Scene ReferenceTypes + + i=32 + + + + UsdAttributeType + USD attribute value variable. The runtime DataType and ValueRank reflect the composed Sdf value type. + OpenUSD Scene Materialization + + i=63 + ns=1;i=6001 + ns=1;i=6002 + ns=1;i=6003 + ns=1;i=6004 + ns=1;i=6005 + ns=1;i=6078 + + + + UsdTypeName + Exact SdfValueTypeName, e.g. 'float3', 'token', 'asset', or 'color3f[]'. + + i=80 + i=68 + ns=1;i=2001 + + + + Variability + USD attribute variability. + + i=80 + i=68 + ns=1;i=2001 + + + + Custom + True if the attribute is a custom USD attribute. + + i=80 + i=68 + ns=1;i=2001 + + + + Namespace + Attribute namespace, e.g. 'primvars' or 'xformOp'. + + i=80 + i=68 + ns=1;i=2001 + + + + Interpolation + Interpolation metadata for primvars and similar authored values. + + i=80 + i=68 + ns=1;i=2001 + + + + UsdStageType + A materialized composed OpenUSD stage. + OpenUSD Scene Materialization + + i=58 + ns=1;i=6006 + ns=1;i=6007 + ns=1;i=6008 + ns=1;i=6009 + ns=1;i=6010 + ns=1;i=6011 + ns=1;i=6012 + ns=1;i=6013 + ns=1;i=6014 + ns=1;i=6021 + + + + DefaultPrim + Default prim token. + + i=80 + i=68 + ns=1;i=1001 + + + + UpAxis + Stage up axis token. + + i=80 + i=68 + ns=1;i=1001 + + + + MetersPerUnit + Stage meters-per-unit metadata. + + i=80 + i=68 + ns=1;i=1001 + + + + KilogramsPerUnit + Stage kilograms-per-unit metadata. + + i=80 + i=68 + ns=1;i=1001 + + + + TimeCodesPerSecond + Stage time-codes-per-second metadata. + + i=80 + i=68 + ns=1;i=1001 + + + + StartTimeCode + Stage start time code. + + i=80 + i=68 + ns=1;i=1001 + + + + EndTimeCode + Stage end time code. + + i=80 + i=68 + ns=1;i=1001 + + + + RootLayerIdentifier + Root layer identifier. + + i=80 + i=68 + ns=1;i=1001 + + + + Documentation + Stage documentation metadata. + + i=80 + i=68 + ns=1;i=1001 + + + + UsdPrimType + A materialized composed USD prim (untyped/over base). + OpenUSD Scene Materialization + + i=58 + ns=1;i=6015 + ns=1;i=6016 + ns=1;i=6017 + ns=1;i=6018 + ns=1;i=6019 + ns=1;i=6020 + ns=1;i=6022 + ns=1;i=6023 + ns=1;i=6024 + ns=1;i=6025 + ns=1;i=6026 + ns=1;i=6027 + ns=1;i=6028 + + + + Specifier + Prim specifier. + + i=80 + i=68 + ns=1;i=1002 + + + + TypeName + Composed USD type name token. + + i=80 + i=68 + ns=1;i=1002 + + + + Kind + Model kind metadata. + + i=80 + i=68 + ns=1;i=1002 + + + + Active + Whether the prim is active. + + i=80 + i=68 + ns=1;i=1002 + + + + Instanceable + Whether the prim is instanceable. + + i=80 + i=68 + ns=1;i=1002 + + + + Documentation + Prim documentation metadata. + + i=80 + i=68 + ns=1;i=1002 + + + + <UsdPrim> + Composed root prims of the materialized stage. + + i=11508 + ns=1;i=1002 + ns=1;i=1001 + + + + <UsdPrim> + Child prims of this composed prim. + + i=11508 + ns=1;i=1002 + ns=1;i=1002 + + + + <UsdAttribute> + USD attributes authored on this prim. + + i=11508 + ns=1;i=2001 + ns=1;i=1002 + + + + <UsdRelationship> + USD relationships authored on this prim. + + i=11508 + ns=1;i=1017 + ns=1;i=1002 + + + + AppliedSchemas + Applied API schema AddIns for this prim. + + i=80 + i=61 + ns=1;i=1002 + ns=1;i=6074 + + + + Composition + Composition arcs contributing to this prim. + + i=80 + i=61 + ns=1;i=1002 + ns=1;i=6075 + + + + VariantSets + Variant sets authored or composed for this prim. + + i=80 + i=61 + ns=1;i=1002 + ns=1;i=6076 + + + + Metadata + Arbitrary metadata Property variables for this prim. + + i=80 + i=61 + ns=1;i=1002 + ns=1;i=6077 + + + + UsdTypedType + Typed (IsA-schema) prim base. + OpenUSD Scene Materialization + + ns=1;i=1002 + + + + UsdGeomImageableType + Abstract USD imageable prim base. + OpenUSD Scene Materialization + + ns=1;i=1003 + ns=1;i=6029 + ns=1;i=6030 + + + + Visibility + Visibility token. + + i=80 + i=68 + ns=1;i=1004 + + + + Purpose + Purpose token. + + i=80 + i=68 + ns=1;i=1004 + + + + UsdGeomXformableType + Abstract USD xformable prim base. + OpenUSD Scene Materialization + + ns=1;i=1004 + ns=1;i=6031 + + + + XformOpOrder + Ordered xformOp token array. + + i=80 + i=68 + ns=1;i=1005 + + + + UsdGeomXformType + USD Xform prim. + OpenUSD Scene Materialization + + ns=1;i=1005 + + + + UsdGeomScopeType + USD Scope prim. + OpenUSD Scene Materialization + + ns=1;i=1004 + + + + UsdGeomGprimType + Abstract USD geometric prim base. + OpenUSD Scene Materialization + + ns=1;i=1005 + ns=1;i=6032 + ns=1;i=6033 + ns=1;i=6034 + + + + DisplayColor + Display color array (color3f[]). + + i=80 + i=68 + ns=1;i=1008 + + + + DisplayOpacity + Display opacity array. + + i=80 + i=68 + ns=1;i=1008 + + + + DoubleSided + Double-sided rendering hint. + + i=80 + i=68 + ns=1;i=1008 + + + + UsdGeomMeshType + USD Mesh prim. + OpenUSD Scene Materialization + + ns=1;i=1008 + ns=1;i=6035 + ns=1;i=6036 + ns=1;i=6037 + + + + Points + Mesh points array. + + i=80 + i=68 + ns=1;i=1009 + + + + FaceVertexCounts + Mesh face vertex counts. + + i=80 + i=68 + ns=1;i=1009 + + + + FaceVertexIndices + Mesh face vertex indices. + + i=80 + i=68 + ns=1;i=1009 + + + + UsdGeomCylinderType + USD Cylinder prim. + OpenUSD Scene Materialization + + ns=1;i=1008 + ns=1;i=6038 + ns=1;i=6039 + ns=1;i=6040 + + + + Height + Cylinder height. + + i=80 + i=68 + ns=1;i=1010 + + + + Radius + Cylinder radius. + + i=80 + i=68 + ns=1;i=1010 + + + + Axis + Cylinder axis token. + + i=80 + i=68 + ns=1;i=1010 + + + + UsdGeomSphereType + USD Sphere prim. + OpenUSD Scene Materialization + + ns=1;i=1008 + ns=1;i=6041 + + + + Radius + Sphere radius. + + i=80 + i=68 + ns=1;i=1011 + + + + UsdGeomCubeType + USD Cube prim. + OpenUSD Scene Materialization + + ns=1;i=1008 + ns=1;i=6042 + + + + Size + Cube size. + + i=80 + i=68 + ns=1;i=1012 + + + + UsdGeomConeType + USD Cone prim. + OpenUSD Scene Materialization + + ns=1;i=1008 + ns=1;i=6043 + ns=1;i=6044 + ns=1;i=6045 + + + + Height + Cone height. + + i=80 + i=68 + ns=1;i=1013 + + + + Radius + Cone radius. + + i=80 + i=68 + ns=1;i=1013 + + + + Axis + Cone axis token. + + i=80 + i=68 + ns=1;i=1013 + + + + UsdGeomCapsuleType + USD Capsule prim. + OpenUSD Scene Materialization + + ns=1;i=1008 + ns=1;i=6046 + ns=1;i=6047 + ns=1;i=6048 + + + + Height + Capsule height. + + i=80 + i=68 + ns=1;i=1014 + + + + Radius + Capsule radius. + + i=80 + i=68 + ns=1;i=1014 + + + + Axis + Capsule axis token. + + i=80 + i=68 + ns=1;i=1014 + + + + UsdShadeMaterialType + USD Shade material prim hook. + OpenUSD Scene Materialization + + ns=1;i=1003 + + + + UsdShadeShaderType + USD Shade shader prim hook. + OpenUSD Scene Materialization + + ns=1;i=1003 + ns=1;i=6049 + + + + Info_Id + Shader implementation identifier token. + + i=80 + i=68 + ns=1;i=1016 + + + + UsdRelationshipType + USD relationship object. + OpenUSD Scene Materialization + + i=58 + ns=1;i=6050 + ns=1;i=6051 + ns=1;i=6052 + + + + Custom + True if this is a custom relationship. + + i=80 + i=68 + ns=1;i=1017 + + + + Targets + Ordered target NodeId list. + + i=78 + i=68 + ns=1;i=1017 + + + + TargetPaths + Ordered SdfPath target strings for fidelity. + + i=78 + i=68 + ns=1;i=1017 + + + + UsdVariantSetType + USD variant set materialization. + OpenUSD Scene Materialization + + i=58 + ns=1;i=6053 + ns=1;i=6054 + ns=1;i=6055 + + + + SetName + Variant set name. + + i=80 + i=68 + ns=1;i=1018 + + + + Selection + Selected variant token. + + i=80 + i=68 + ns=1;i=1018 + + + + <Variant> + Variant branch holding prim overrides. + + i=11508 + ns=1;i=1002 + ns=1;i=1018 + + + + UsdCompositionArcType + USD composition arc materialization. + OpenUSD Scene Materialization + + i=58 + ns=1;i=6056 + ns=1;i=6057 + ns=1;i=6058 + ns=1;i=6059 + ns=1;i=6060 + ns=1;i=6061 + + + + ArcKind + Composition arc kind. + + i=80 + i=68 + ns=1;i=1019 + + + + AssetPath + Referenced or payload asset path. + + i=80 + i=68 + ns=1;i=1019 + + + + PrimPath + Target prim path. + + i=80 + i=68 + ns=1;i=1019 + + + + ListPosition + List operation position/type. + + i=80 + i=68 + ns=1;i=1019 + + + + VariantSet + Variant set name for variant arcs. + + i=80 + i=68 + ns=1;i=1019 + + + + VariantSelection + Variant selection name. + + i=80 + i=68 + ns=1;i=1019 + + + + UsdApiSchemaType + Base for applied USD API schema AddIns. Concrete, because ยง8.4 requires an unknown applied schema to degrade to a UsdApiSchemaType AddIn carrying its SchemaName rather than being dropped; vendors still subtype it (ยง8.2). + OpenUSD Scene Materialization + + i=58 + ns=1;i=6062 + + + + SchemaName + Applied API schema name. + + i=80 + i=68 + ns=1;i=1020 + + + + UsdCollectionAPIType + USD CollectionAPI applied API schema. + OpenUSD Scene Materialization + + ns=1;i=1020 + ns=1;i=6063 + ns=1;i=6064 + ns=1;i=6065 + + + + ExpansionRule + Collection expansion rule token. + + i=80 + i=68 + ns=1;i=1021 + + + + IncludesTargets + Included target NodeIds. + + i=80 + i=68 + ns=1;i=1021 + + + + ExcludesTargets + Excluded target NodeIds. + + i=80 + i=68 + ns=1;i=1021 + + + + UsdGeoreferenceApiType + Portable stage georeference applied API schema: the geodetic origin that anchors the stage's local Cartesian frame to the globe. Vendor-neutral materialization of Cesium CesiumGeoreferencePrim / NVIDIA WGS84ReferencePositionAPI; maps to OPC UA GPOS (OPC 10000-211) GlobalPosition + GroundControlPoints (Annex B). + OpenUSD Scene Materialization + + ns=1;i=1020 + ns=1;i=6066 + ns=1;i=6067 + ns=1;i=6068 + ns=1;i=6069 + ns=1;i=6070 + + + + Latitude + Origin latitude in decimal degrees (WGS84 unless EpsgCode indicates otherwise). + + i=80 + i=68 + ns=1;i=1022 + + + + Longitude + Origin longitude in decimal degrees. + + i=80 + i=68 + ns=1;i=1022 + + + + Height + Origin height above the ellipsoid in metres. + + i=80 + i=68 + ns=1;i=1022 + + + + EpsgCode + EPSG coordinate reference system code (0 = local, 4326 = WGS84/GPS). + + i=80 + i=68 + ns=1;i=1022 + + + + TangentPlane + Local tangent-plane convention token (e.g. ENU or NED). + + i=80 + i=68 + ns=1;i=1022 + + + + UsdGlobeAnchorApiType + Portable per-prim globe anchor applied API schema: the geodetic position of an individual prim, resolved against the stage UsdGeoreferenceApiType. Vendor-neutral materialization of Cesium CesiumGlobeAnchorAPI / NVIDIA WGS84LocalPositionAPI; maps to a per-asset OPC UA GPOS GlobalPosition. + OpenUSD Scene Materialization + + ns=1;i=1020 + ns=1;i=6071 + ns=1;i=6072 + ns=1;i=6073 + + + + Latitude + Prim latitude in decimal degrees. + + i=80 + i=68 + ns=1;i=1023 + + + + Longitude + Prim longitude in decimal degrees. + + i=80 + i=68 + ns=1;i=1023 + + + + Height + Prim height above the ellipsoid in metres. + + i=80 + i=68 + ns=1;i=1023 + + + + <UsdApiSchema> + Applied API schema AddIn instances. + + i=11508 + ns=1;i=1020 + ns=1;i=6025 + + + + <UsdCompositionArc> + Composition arcs contributing opinions. + + i=11508 + ns=1;i=1019 + ns=1;i=6026 + + + + <UsdVariantSet> + Variant sets available on this prim. + + i=11508 + ns=1;i=1018 + ns=1;i=6027 + + + + <Metadata> + Arbitrary metadata properties. + + i=11508 + i=68 + ns=1;i=6028 + + + + ConnectionPaths + Ordered SdfPath strings of the attribute's authored connections, the connection counterpart of UsdRelationshipType.TargetPaths: a connection whose target lies outside the materialized subtree has no browsable UsdConnection edge, and without this member it could not be exported. + + i=80 + i=68 + ns=1;i=2001 + + + diff --git a/src/Opc.Ua.OpenUsdScene/NugetREADME.md b/src/Opc.Ua.OpenUsdScene/NugetREADME.md new file mode 100644 index 0000000000..cf556b9a42 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/NugetREADME.md @@ -0,0 +1,36 @@ +# Opc.Ua.OpenUsdScene + +Companion information model for the draft **OPC UA โ€” OpenUSD Scene Materialization (Part 2)** +specification, source-generated from the shared `Opc.Ua.OpenUsdScene.NodeSet2.xml`. + +Where the Bindings model (Part 1, `Opc.Ua.OpenUsd`) keeps the USD scene *outside* OPC UA and binds +process values to it, Scene Materialization puts the scene *inside* the address space: the composed +prim tree becomes the OPC UA node hierarchy, so browsing the server is browsing the scene. + +## What is in the model + +| Area | Types | +|---|---| +| Stage and prims | `UsdStageType`, `UsdPrimType`, `UsdTypedType` | +| Typed prim hierarchy | `UsdGeomImageableType`, `UsdGeomScopeType`, `UsdGeomXformableType`, `UsdGeomXformType`, `UsdGeomGprimType`, `UsdGeomMeshType`, `UsdGeomCylinderType`, `UsdGeomSphereType`, `UsdGeomCubeType`, `UsdGeomConeType`, `UsdGeomCapsuleType`, `UsdShadeMaterialType`, `UsdShadeShaderType` | +| Properties | `UsdAttributeType` (VariableType), `UsdRelationshipType` | +| Composition | `UsdCompositionArcType`, `UsdVariantSetType` | +| Applied schemas | `UsdApiSchemaType`, `UsdCollectionAPIType` | +| Georeferencing | `UsdGeoreferenceApiType`, `UsdGlobeAnchorApiType` | +| Enumerations | `UsdSpecifierEnum`, `UsdVariabilityEnum`, `UsdPrimKindEnum`, `UsdListOpTypeEnum`, `UsdArcKindEnum` | +| Value-role DataTypes | `UsdToken`, `UsdAssetPath`, `UsdTimeCode`, `UsdColor3f`, `UsdNormal3f`, `UsdPoint3f`, `UsdVector3f`, `UsdTexCoord2f`, `UsdQuatf`, `UsdQuatd`, `UsdMatrix4d` | +| ReferenceTypes | `UsdRelationshipTarget`, `UsdConnection` | + +The value-role DataTypes follow the OPC UA idiom of conveying meaning by extending a primitive +(`Duration : Double`, `UtcTime : DateTime`), so a role such as *colour* versus *point* is discoverable +from the type system while the built-in encoding stays unchanged for generic clients. + +## Related packages + +- `Opc.Ua.OpenUsdScene.Server` โ€” materializes a scene into a server address space and exports it back +- `Opc.Ua.OpenUsd` โ€” the Part 1 Bindings model +- `Opc.Ua.OpenUsd.Client` โ€” the Part 1 connector + +> This package tracks a **draft** specification; the model URI, versions and NodeIds may change. + +Namespace URI: `http://opcfoundation.org/UA/OpenUSD/Scene/` diff --git a/src/Opc.Ua.OpenUsdScene/Opc.Ua.OpenUsdScene.csproj b/src/Opc.Ua.OpenUsdScene/Opc.Ua.OpenUsdScene.csproj new file mode 100644 index 0000000000..6d27a18ef7 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Opc.Ua.OpenUsdScene.csproj @@ -0,0 +1,50 @@ + + + $(AssemblyPrefix).OpenUsdScene + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.OpenUsdScene + Opc.Ua.OpenUsdScene + $(NoWarn);CS1591 + enable + Draft OPC UA - OpenUSD Scene Materialization companion information model (stages, prims, attributes, relationships, composition arcs, variant sets, applied API schemas and georeferencing), source-generated from the shared NodeSet2.xml. + true + NugetREADME.md + MIT + true + true + + + + + + + $(PackageId).Debug + + + + + + + Analyzer + false + + + + + + + + + Opc.Ua.OpenUsdScene + + + + + + v105 + true + + + + + diff --git a/src/Opc.Ua.OpenUsdScene/Properties/AssemblyInfo.cs b/src/Opc.Ua.OpenUsdScene/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdApiSchema.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdApiSchema.cs new file mode 100644 index 0000000000..2f22819500 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdApiSchema.cs @@ -0,0 +1,80 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.OpenUsdScene.Scene +{ + /// + /// An applied USD API schema, materialized as an AddIn under a prim's + /// AppliedSchemas folder (draft OPC UA โ€” OpenUSD Scene Materialization ยง5.6, ยง8.2). + /// An unknown API schema is never dropped: it degrades to a generic + /// UsdApiSchemaType AddIn carrying (ยง8.4). + /// + public sealed class UsdApiSchema + { + private static readonly char[] s_colon = [':']; + + /// + /// Creates an applied API schema. + /// + /// The schema token as authored, for example + /// CollectionAPI:lights or CesiumGlobeAnchorAPI. + public UsdApiSchema(string schemaName) + { + SchemaName = schemaName ?? string.Empty; + string[] parts = SchemaName.Split(s_colon, 2); + FamilyName = parts[0]; + InstanceName = parts.Length > 1 ? parts[1] : string.Empty; + } + + /// + /// The applied schema token exactly as authored. + /// + public string SchemaName { get; } + + /// + /// The instance name of a multiple-apply schema โ€” the portion after the colon + /// (for example lights in CollectionAPI:lights), or an empty string + /// for a single-apply schema. + /// + public string InstanceName { get; } + + /// + /// The schema family name โ€” the portion before the colon (for example + /// CollectionAPI), which selects the materialized AddIn ObjectType. + /// + public string FamilyName { get; } + + /// + /// The expansion rule of a collection-style schema, when authored. + /// + public string ExpansionRule { get; set; } = string.Empty; + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdAttribute.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdAttribute.cs new file mode 100644 index 0000000000..d0282e0feb --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdAttribute.cs @@ -0,0 +1,122 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.OpenUsdScene.Scene +{ + /// + /// A materialized USD attribute โ€” a typed, valued property of a prim + /// (draft OPC UA โ€” OpenUSD Scene Materialization ยง5.4). + /// + public sealed class UsdAttribute + { + /// + /// Creates an attribute. + /// + /// The full property name, including any namespace prefix + /// (for example xformOp:rotateZ or primvars:displayColor). + /// The exact SdfValueTypeName (for example + /// float3, token, color3f[]). + public UsdAttribute(string name, string typeName) + { + Name = name ?? string.Empty; + TypeName = typeName ?? string.Empty; + int idx = Name.LastIndexOf(':'); + Namespace = idx < 0 ? string.Empty : Name.Substring(0, idx); + BaseName = idx < 0 ? Name : Name.Substring(idx + 1); + } + + /// + /// The full property name including any namespace prefix. + /// + public string Name { get; } + + /// + /// The exact SdfValueTypeName. Always retained so an export reproduces the + /// precise spelling even where several USD types share one OPC UA DataType (ยง6.2). + /// + public string TypeName { get; } + + /// + /// The resolved attribute value, or null when the attribute is declared but + /// carries no authored default. + /// + public object? Value { get; set; } + + /// + /// The authored USD time samples, an ordered map from time code to value kept separate + /// from the authored default in (ยง7.1 step 3, ยง9). An attribute may + /// carry a default, samples, both, or neither. The map is ordered by time code ascending + /// (USD's composed sample order), so a materializer exposes as the + /// live default and each sample as a HistoricalAccess entry (ยง9). A negative or fractional + /// time code is permitted. When empty the attribute has no time samples. + /// + public SortedList TimeSamples { get; } = new SortedList(); + + /// + /// Whether the attribute may vary over time. + /// + public UsdVariabilityEnum Variability { get; set; } = UsdVariabilityEnum.Varying; + + /// + /// Whether the attribute is a custom (non-schema) property. + /// + public bool Custom { get; set; } + + /// + /// Interpolation for a primvar, when authored (for example constant, vertex). + /// + public string? Interpolation { get; set; } + + /// + /// Authored attribute connections as SdfPath strings, materialized as + /// UsdConnection references (ยง5.4). + /// + public IList Connections { get; } = new List(); + + /// + /// Whether the attribute is server-maintained and time-varying โ€” Mode A of ยง9. + /// A Mode B (static) attribute keeps its authored default. + /// + public bool Live { get; set; } + + /// + /// The property namespace โ€” the portion of before the last colon + /// (for example xformOp), or an empty string when the attribute is unnamespaced. + /// + public string Namespace { get; } + + /// + /// The property name without its namespace prefix. + /// + public string BaseName { get; } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdCompositionArc.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdCompositionArc.cs new file mode 100644 index 0000000000..b63daff559 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdCompositionArc.cs @@ -0,0 +1,78 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.OpenUsdScene.Scene +{ + /// + /// A recorded composition arc โ€” how a composed prim came to be + /// (draft OPC UA โ€” OpenUSD Scene Materialization ยง5.6). Preserving the arc list is + /// what makes the ยง7.4 round-trip contract provenance-aware. + /// + public sealed class UsdCompositionArc + { + /// + /// Creates a composition arc. + /// + /// The kind of arc. + public UsdCompositionArc(UsdArcKindEnum arcKind) + { + ArcKind = arcKind; + } + + /// + /// The kind of composition arc. + /// + public UsdArcKindEnum ArcKind { get; } + + /// + /// The referenced asset path, when the arc names one. + /// + public string AssetPath { get; set; } = string.Empty; + + /// + /// The target prim path within the referenced asset, when authored. + /// + public string PrimPath { get; set; } = string.Empty; + + /// + /// The list-edit position the arc was authored with. + /// + public UsdListOpTypeEnum ListPosition { get; set; } = UsdListOpTypeEnum.Explicit; + + /// + /// The variant set name, for a arc. + /// + public string VariantSet { get; set; } = string.Empty; + + /// + /// The selected variant, for a arc. + /// + public string VariantSelection { get; set; } = string.Empty; + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdPrim.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdPrim.cs new file mode 100644 index 0000000000..03a9fbd68c --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdPrim.cs @@ -0,0 +1,211 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Text; + +namespace Opc.Ua.OpenUsdScene.Scene +{ + /// + /// A prim in a composed USD stage โ€” the unit that becomes an OPC UA Object in the + /// materialized address space (draft OPC UA โ€” OpenUSD Scene Materialization ยง5.2, ยง5.3). + /// The prim hierarchy *is* the node hierarchy, so browsing the address space is + /// browsing the scene. + /// + public sealed class UsdPrim + { + /// + /// Creates a prim. + /// + /// The prim name (the last element of its path). + /// The USD schema type token, or an empty string when the + /// prim is untyped. + public UsdPrim(string name, string typeName = "") + { + Name = name ?? string.Empty; + TypeName = typeName ?? string.Empty; + } + + /// + /// The prim name. + /// + public string Name { get; } + + /// + /// The USD schema type token exactly as authored (for example Xform, + /// Mesh). Retained even when the type is unknown to this model, so an + /// unknown typed schema degrades rather than being dropped (ยง8.4). + /// + public string TypeName { get; set; } + + /// + /// Whether the prim is a definition, an override or a class. + /// + public UsdSpecifierEnum Specifier { get; set; } = UsdSpecifierEnum.Def; + + /// + /// The model kind of the prim. + /// + public UsdPrimKindEnum Kind { get; set; } = UsdPrimKindEnum.Unspecified; + + /// + /// Whether the prim is active. An inactive prim is pruned from the composed scene. + /// + public bool Active { get; set; } = true; + + /// + /// Whether the prim is instanceable. + /// + public bool Instanceable { get; set; } + + /// + /// Free-form documentation authored on the prim. + /// + public string Documentation { get; set; } = string.Empty; + + /// + /// The prim's attributes, in authored order. + /// + public IList Attributes { get; } = new List(); + + /// + /// The prim's relationships, in authored order. + /// + public IList Relationships { get; } = new List(); + + /// + /// The prim's composition arcs (ยง5.6). + /// + public IList Composition { get; } = new List(); + + /// + /// The API schemas applied to the prim (ยง5.6, ยง8.2). + /// + public IList ApiSchemas { get; } = new List(); + + /// + /// The prim's variant sets and their selections (ยง5.6). + /// + public IList VariantSets { get; } = new List(); + + /// + /// Metadata authored on the prim that has no well-known typed member; materialized + /// under the prim's Metadata folder (ยง6.1). + /// + public IDictionary Metadata { get; } = + new Dictionary(StringComparer.Ordinal); + + /// + /// The child prims, in authored order. + /// + public IList Children { get; } = new List(); + + /// + /// The parent prim, or null for a root prim. + /// + public UsdPrim? Parent { get; internal set; } + + /// + /// The absolute SdfPath of the prim, composed from the parent chain. + /// + public string Path + { + get + { + var parts = new List(); + for (UsdPrim? c = this; c != null; c = c.Parent) + { + parts.Add(c.Name); + } + parts.Reverse(); + var sb = new StringBuilder(); + foreach (string part in parts) + { + sb.Append('/').Append(part); + } + return sb.ToString(); + } + } + + /// + /// Adds a child prim and links it to this parent. + /// + /// The child to add. + /// The added child, for chaining. + public UsdPrim AddChild(UsdPrim child) + { + if (child == null) + { + throw new ArgumentNullException(nameof(child)); + } + child.Parent = this; + Children.Add(child); + return child; + } + + /// + /// Finds a prim at the given absolute path within this subtree. + /// + /// The absolute SdfPath to find. + /// The prim, or null when the subtree contains no such path. + public UsdPrim? Find(string path) + { + if (string.Equals(Path, path, StringComparison.Ordinal)) + { + return this; + } + foreach (UsdPrim child in Children) + { + UsdPrim? found = child.Find(path); + if (found != null) + { + return found; + } + } + return null; + } + + /// + /// Enumerates this prim and every descendant, depth first in authored order. + /// + /// The prim subtree. + public IEnumerable DescendantsAndSelf() + { + yield return this; + foreach (UsdPrim child in Children) + { + foreach (UsdPrim descendant in child.DescendantsAndSelf()) + { + yield return descendant; + } + } + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdRelationship.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdRelationship.cs new file mode 100644 index 0000000000..2e29369d85 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdRelationship.cs @@ -0,0 +1,66 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; + +namespace Opc.Ua.OpenUsdScene.Scene +{ + /// + /// A materialized USD relationship โ€” an ordered set of target paths + /// (draft OPC UA โ€” OpenUSD Scene Materialization ยง5.5). + /// + public sealed class UsdRelationship + { + /// + /// Creates a relationship. + /// + /// The relationship name (for example material:binding). + public UsdRelationship(string name) + { + Name = name ?? string.Empty; + } + + /// + /// The relationship name. + /// + public string Name { get; } + + /// + /// The ordered target SdfPath strings. Materialized both as the TargetPaths + /// property (for fidelity when a target lies outside the materialized subtree) and, + /// for each resolved target, as a UsdRelationshipTarget reference. + /// + public IList Targets { get; } = new List(); + + /// + /// Whether the relationship is a custom (non-schema) property. + /// + public bool Custom { get; set; } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdStage.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdStage.cs new file mode 100644 index 0000000000..decf023f93 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdStage.cs @@ -0,0 +1,157 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.OpenUsdScene.Scene +{ + /// + /// A composed USD stage โ€” the root of a materialized scene + /// (draft OPC UA โ€” OpenUSD Scene Materialization ยง5.1). + /// + public sealed class UsdStage + { + /// + /// Creates a stage. + /// + /// The stage name, used as the BrowseName of the + /// materialized UsdStageType Object. + public UsdStage(string stageName) + { + StageName = stageName ?? string.Empty; + } + + /// + /// The stage name. + /// + public string StageName { get; } + + /// + /// The identifier of the root layer the stage was composed from. + /// + public string RootLayerIdentifier { get; set; } = string.Empty; + + /// + /// The stage's default prim name, when authored. + /// + public string DefaultPrim { get; set; } = string.Empty; + + /// + /// The stage up axis (Y or Z). Does not auto-reconcile with a geodetic + /// frame; it is recorded so a consumer can compose the correct local transform (ยง5.8). + /// + public string UpAxis { get; set; } = "Z"; + + /// + /// Metres per stage unit. + /// + public double MetersPerUnit { get; set; } = 1.0; + + /// + /// Kilograms per stage mass unit, when authored. + /// + public double? KilogramsPerUnit { get; set; } + + /// + /// Time codes per second, when authored. Relates USD time codes to wall clock only + /// together with an explicit epoch declared by a recording profile (ยง9). + /// + public double? TimeCodesPerSecond { get; set; } + + /// + /// The start of the stage timeline, when authored. + /// + public double? StartTimeCode { get; set; } + + /// + /// The end of the stage timeline, when authored. + /// + public double? EndTimeCode { get; set; } + + /// + /// Free-form documentation authored on the stage. + /// + public string Documentation { get; set; } = string.Empty; + + /// + /// The composed root prims. + /// + public IList RootPrims { get; } = new List(); + + /// + /// Adds a root prim to the stage. + /// + /// The prim to add. + /// The added prim, for chaining. + public UsdPrim AddRootPrim(UsdPrim prim) + { + if (prim == null) + { + throw new ArgumentNullException(nameof(prim)); + } + prim.Parent = null; + RootPrims.Add(prim); + return prim; + } + + /// + /// Enumerates every prim in the stage, depth first in authored order. + /// + /// All prims. + public IEnumerable AllPrims() + { + foreach (UsdPrim root in RootPrims) + { + foreach (UsdPrim prim in root.DescendantsAndSelf()) + { + yield return prim; + } + } + } + + /// + /// Finds a prim by absolute path. + /// + /// The absolute SdfPath. + /// The prim, or null when the stage contains no such path. + public UsdPrim? Find(string path) + { + foreach (UsdPrim root in RootPrims) + { + UsdPrim? found = root.Find(path); + if (found != null) + { + return found; + } + } + return null; + } + } +} diff --git a/src/Opc.Ua.OpenUsdScene/Scene/UsdVariantSet.cs b/src/Opc.Ua.OpenUsdScene/Scene/UsdVariantSet.cs new file mode 100644 index 0000000000..6beca7d480 --- /dev/null +++ b/src/Opc.Ua.OpenUsdScene/Scene/UsdVariantSet.cs @@ -0,0 +1,74 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; + +namespace Opc.Ua.OpenUsdScene.Scene +{ + /// + /// A variant set on a prim together with its resolved selection and the full set of + /// authored variant branches (draft OPC UA โ€” OpenUSD Scene Materialization ยง5.6). + /// + public sealed class UsdVariantSet + { + /// + /// Creates a variant set. + /// + /// The variant set name. + /// The selected variant, or an empty string when nothing + /// is selected. + public UsdVariantSet(string setName, string selection = "") + { + SetName = setName ?? string.Empty; + Selection = selection ?? string.Empty; + } + + /// + /// The variant set name. + /// + public string SetName { get; } + + /// + /// The selected variant. + /// + public string Selection { get; set; } + + /// + /// The authored variant branches of this set, in authored order (ยง5.6). Each branch + /// is prim-shaped: its is the variant name and its + /// attributes, relationships and child prims are the content the branch contributes + /// when it is the selection. All branches are captured โ€” not only the selected one โ€” + /// so the Composition Provenance CU can materialize the full + /// <Variant> structure the model defines under UsdVariantSetType. + /// This authoring provenance is intentionally separate from the composed result and + /// therefore excluded from the ยง7.4 composed-scene signature. + /// + public IList Variants { get; } = new List(); + } +} diff --git a/src/Opc.Ua.Positioning.Client/GlobalPositioningClient.cs b/src/Opc.Ua.Positioning.Client/GlobalPositioningClient.cs new file mode 100644 index 0000000000..2a2e49e113 --- /dev/null +++ b/src/Opc.Ua.Positioning.Client/GlobalPositioningClient.cs @@ -0,0 +1,431 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; +using Opc.Ua.Client.Subscriptions; +using Opc.Ua.Client.Subscriptions.Streaming; +using Opc.Ua.Gpos; +using Opc.Ua.Rsl; +using MonitoringOptions = + Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions; + +namespace Opc.Ua.Positioning.Client +{ + /// + /// High-level client for the OPC 10000-211 Global Positioning model. + /// + public sealed class GlobalPositioningClient + { + private readonly PositioningClientOperations m_operations; + + /// + /// Creates a GPOS client. + /// + public GlobalPositioningClient( + ISession session, + ITelemetryContext telemetry) + { + m_operations = new PositioningClientOperations(session, telemetry); + session.Factory.Builder.AddOpcUaGpos().Commit(); + session.MessageContext.Factory.Builder.AddOpcUaGpos().Commit(); + } + + /// + /// OPC UA session used by this client. + /// + public ISession Session => m_operations.Session; + + /// + /// Telemetry context used by generated proxies. + /// + public ITelemetryContext Telemetry => m_operations.Telemetry; + + /// + /// Standardized GlobalLocations entry point. + /// + public NodeId GlobalLocationsId => NodeId.Create( + Opc.Ua.Gpos.Objects.GlobalLocations, + Opc.Ua.Gpos.Namespaces.GPOS, + Session.NamespaceUris); + + /// + /// Enumerates Zones organized by GlobalLocations. + /// + public async IAsyncEnumerable EnumerateZonesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArrayOf references = await m_operations.BrowseAsync( + GlobalLocationsId, + ReferenceTypeIds.Organizes, + (uint)NodeClass.Object, + cancellationToken).ConfigureAwait(false); + + ExpandedNodeId expectedType = Opc.Ua.Gpos.ObjectTypeIds.ZoneType; + for (int i = 0; i < references.Count; i++) + { + ReferenceDescription reference = references[i]; + if (await Session.NodeCache.IsTypeOfAsync( + reference.TypeDefinition, + expectedType, + cancellationToken).ConfigureAwait(false)) + { + yield return m_operations.CreateEntry(reference); + } + } + } + + /// + /// Opens the generated proxy for a Zone. + /// + public ZoneTypeClient OpenZone(NodeId nodeId) + { + return new ZoneTypeClient(Session, nodeId, Telemetry); + } + + /// + /// Reads a GlobalPositionType Variable and its projection metadata. + /// + public async ValueTask ReadGlobalPositionAsync( + NodeId nodeId, + CancellationToken cancellationToken = default) + { + DataValue dataValue = await Session.ReadValueAsync( + nodeId, + cancellationToken).ConfigureAwait(false); + GlobalPositionDataType position = DecodeStructure( + dataValue, + "GlobalPosition"); + ( + NodeId sourceId, + uint coordinateReferenceSystem + ) = await ReadPositionMetadataAsync( + nodeId, + cancellationToken).ConfigureAwait(false); + return new GlobalPositionValue( + nodeId, + position, + sourceId, + coordinateReferenceSystem, + dataValue.StatusCode, + dataValue.SourceTimestamp); + } + + /// + /// Reads a GlobalLocationType Variable. + /// + public async ValueTask ReadGlobalLocationAsync( + NodeId nodeId, + CancellationToken cancellationToken = default) + { + ushort rslNamespaceIndex = Session.NamespaceUris.GetIndexOrAppend( + Opc.Ua.Rsl.Namespaces.RSL); + NodeId positionId = await m_operations.ResolveRequiredChildAsync( + nodeId, + ReferenceTypeIds.HasComponent, + new QualifiedName("Position", rslNamespaceIndex), + cancellationToken).ConfigureAwait(false); + + DataValue dataValue = DataValue.Null; + GlobalLocationDataType location = null!; + bool coherent = false; + for (int attempt = 0; attempt < 3; attempt++) + { + dataValue = await Session.ReadValueAsync( + nodeId, + cancellationToken).ConfigureAwait(false); + location = DecodeStructure( + dataValue, + "GlobalLocation"); + GlobalPositionDataType componentPosition = + await m_operations.ReadStructureAsync( + positionId, + cancellationToken).ConfigureAwait(false); + if (location.Position.IsEqual(componentPosition)) + { + coherent = true; + break; + } + await Task.Yield(); + } + if (!coherent) + { + throw new ServiceResultException( + StatusCodes.BadDataEncodingInvalid, + "GlobalLocation aggregate and Position component values differ."); + } + + ( + NodeId sourceId, + uint coordinateReferenceSystem + ) = await ReadPositionMetadataAsync( + positionId, + cancellationToken).ConfigureAwait(false); + return new GlobalLocationValue( + nodeId, + location, + sourceId, + coordinateReferenceSystem, + dataValue.StatusCode, + dataValue.SourceTimestamp); + } + + /// + /// Reads and fits the GroundControlPoints exposed by a Zone. + /// + public async ValueTask ReadZoneTransformAsync( + NodeId zoneId, + GroundControlPointFitOptions? options = null, + CancellationToken cancellationToken = default) + { + ushort namespaceIndex = Session.NamespaceUris.GetIndexOrAppend( + Opc.Ua.Gpos.Namespaces.GPOS); + NodeId groundControlPointsId = + await m_operations.ResolveRequiredChildAsync( + zoneId, + ReferenceTypeIds.HasComponent, + new QualifiedName("GroundControlPoints", namespaceIndex), + cancellationToken).ConfigureAwait(false); + ArrayOf groundControlPoints = + await m_operations + .ReadStructureArrayAsync( + groundControlPointsId, + cancellationToken) + .ConfigureAwait(false); + try + { + return new GroundControlPointFitter( + options?.CoordinateReferenceSystem) + .Fit(groundControlPoints, options); + } + catch (ArgumentException ex) + { + throw new ServiceResultException( + StatusCodes.BadInvalidArgument, + "The Zone GroundControlPoints do not define a valid transform.", + ex); + } + } + + /// + /// Converts a global coordinate to the local coordinate system of a Zone. + /// + public async ValueTask GlobalToLocalAsync( + NodeId zoneId, + S3DGeographicCoordinateDataType globalPosition, + AngleUnit angleUnit, + GroundControlPointFitOptions? options = null, + CancellationToken cancellationToken = default) + { + globalPosition.ThrowIfNull(nameof(globalPosition)); + GroundControlPointFitResult transform = await ReadZoneTransformAsync( + zoneId, + options, + cancellationToken).ConfigureAwait(false); + return transform.GlobalToLocal(globalPosition, angleUnit); + } + + /// + /// Converts a local Zone coordinate to a global coordinate. + /// + public async ValueTask LocalToGlobalAsync( + NodeId zoneId, + ThreeDCartesianCoordinates localPosition, + AngleUnit angleUnit, + GroundControlPointFitOptions? options = null, + CancellationToken cancellationToken = default) + { + localPosition.ThrowIfNull(nameof(localPosition)); + GroundControlPointFitResult transform = await ReadZoneTransformAsync( + zoneId, + options, + cancellationToken).ConfigureAwait(false); + return transform.LocalToGlobal(localPosition, angleUnit); + } + + /// + /// Streams changes to a GlobalPositionType Variable. + /// + public IAsyncEnumerable ObserveGlobalPositionAsync( + NodeId nodeId, + IStreamingSubscription streaming, + MonitoringOptions? options = null, + CancellationToken cancellationToken = default) + { + streaming.ThrowIfNull(nameof(streaming)); + return ObserveGlobalPositionImplAsync( + nodeId, + streaming, + options, + cancellationToken); + } + + /// + /// Streams changes to a GlobalLocationType Variable. + /// + public IAsyncEnumerable ObserveGlobalLocationAsync( + NodeId nodeId, + IStreamingSubscription streaming, + MonitoringOptions? options = null, + CancellationToken cancellationToken = default) + { + streaming.ThrowIfNull(nameof(streaming)); + return ObserveGlobalLocationImplAsync( + nodeId, + streaming, + options, + cancellationToken); + } + + private async IAsyncEnumerable + ObserveGlobalPositionImplAsync( + NodeId nodeId, + IStreamingSubscription streaming, + MonitoringOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + ( + NodeId sourceId, + uint coordinateReferenceSystem + ) = await ReadPositionMetadataAsync( + nodeId, + cancellationToken).ConfigureAwait(false); + await foreach (DataValueChange change in streaming + .SubscribeDataChangesAsync( + nodeId, + options, + cancellationToken) + .ConfigureAwait(false)) + { + GlobalPositionDataType position = DecodeStructure( + change.Value, + "GlobalPosition"); + yield return new GlobalPositionValue( + nodeId, + position, + sourceId, + coordinateReferenceSystem, + change.Value.StatusCode, + change.Value.SourceTimestamp); + } + } + + private async IAsyncEnumerable + ObserveGlobalLocationImplAsync( + NodeId nodeId, + IStreamingSubscription streaming, + MonitoringOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + ushort rslNamespaceIndex = Session.NamespaceUris.GetIndexOrAppend( + Opc.Ua.Rsl.Namespaces.RSL); + NodeId positionId = await m_operations.ResolveRequiredChildAsync( + nodeId, + ReferenceTypeIds.HasComponent, + new QualifiedName("Position", rslNamespaceIndex), + cancellationToken).ConfigureAwait(false); + ( + NodeId sourceId, + uint coordinateReferenceSystem + ) = await ReadPositionMetadataAsync( + positionId, + cancellationToken).ConfigureAwait(false); + + await foreach (DataValueChange change in streaming + .SubscribeDataChangesAsync( + nodeId, + options, + cancellationToken) + .ConfigureAwait(false)) + { + GlobalLocationDataType location = DecodeStructure( + change.Value, + "GlobalLocation"); + yield return new GlobalLocationValue( + nodeId, + location, + sourceId, + coordinateReferenceSystem, + change.Value.StatusCode, + change.Value.SourceTimestamp); + } + } + + private async ValueTask<(NodeId SourceId, uint CoordinateReferenceSystem)> + ReadPositionMetadataAsync( + NodeId positionId, + CancellationToken cancellationToken) + { + ushort namespaceIndex = Session.NamespaceUris.GetIndexOrAppend( + Opc.Ua.Gpos.Namespaces.GPOS); + NodeId sourceIdNode = await m_operations.ResolveRequiredChildAsync( + positionId, + ReferenceTypeIds.HasProperty, + new QualifiedName("SourceId", namespaceIndex), + cancellationToken).ConfigureAwait(false); + NodeId coordinateReferenceSystemNode = + await m_operations.ResolveRequiredChildAsync( + positionId, + ReferenceTypeIds.HasComponent, + new QualifiedName( + "CoordinateReferenceSystem", + namespaceIndex), + cancellationToken).ConfigureAwait(false); + NodeId sourceId = await m_operations.ReadNodeIdAsync( + sourceIdNode, + cancellationToken).ConfigureAwait(false); + uint coordinateReferenceSystem = + await m_operations.ReadUInt32Async( + coordinateReferenceSystemNode, + cancellationToken).ConfigureAwait(false); + return (sourceId, coordinateReferenceSystem); + } + + private T DecodeStructure( + DataValue dataValue, + string displayName) + where T : class, IEncodeable + { + T value; + if (!dataValue.WrappedValue.TryGetValue( + out value!, + Session.MessageContext)) + { + throw new ServiceResultException( + StatusCodes.BadTypeMismatch, + $"The {displayName} Variable does not contain a {typeof(T).Name} value."); + } + return value; + } + } +} diff --git a/src/Opc.Ua.Positioning.Client/Hosting/OpcUaPositioningClientBuilderExtensions.cs b/src/Opc.Ua.Positioning.Client/Hosting/OpcUaPositioningClientBuilderExtensions.cs new file mode 100644 index 0000000000..367767f7c7 --- /dev/null +++ b/src/Opc.Ua.Positioning.Client/Hosting/OpcUaPositioningClientBuilderExtensions.cs @@ -0,0 +1,90 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Positioning.Client; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Registers Positioning clients over the managed OPC UA session. + /// + public static class OpcUaPositioningClientBuilderExtensions + { + /// + /// Registers RSL and GPOS client factories. + /// + public static IOpcUaClientBuilder AddPositioningClient( + this IOpcUaClientBuilder builder) + { + builder.ThrowIfNull(nameof(builder)); + + builder.Services.TryAddSingleton< + Func>>(sp => + { + Func> sessionFactory = + sp.GetService>>() ?? + throw new InvalidOperationException( + "AddPositioningClient requires AddClient to be called first."); + ITelemetryContext telemetry = + sp.GetRequiredService(); + return async cancellationToken => + { + ManagedSession session = await sessionFactory(cancellationToken) + .ConfigureAwait(false); + return new RelativeSpatialLocationClient(session, telemetry); + }; + }); + + builder.Services.TryAddSingleton< + Func>>(sp => + { + Func> sessionFactory = + sp.GetService>>() ?? + throw new InvalidOperationException( + "AddPositioningClient requires AddClient to be called first."); + ITelemetryContext telemetry = + sp.GetRequiredService(); + return async cancellationToken => + { + ManagedSession session = await sessionFactory(cancellationToken) + .ConfigureAwait(false); + return new GlobalPositioningClient(session, telemetry); + }; + }); + + return builder; + } + } +} diff --git a/src/Opc.Ua.Positioning.Client/NugetREADME.md b/src/Opc.Ua.Positioning.Client/NugetREADME.md new file mode 100644 index 0000000000..f8fb84c43a --- /dev/null +++ b/src/Opc.Ua.Positioning.Client/NugetREADME.md @@ -0,0 +1,14 @@ +# OPC UA .NET Standard โ€” Positioning Client + +`OPCFoundation.NetStandard.Opc.Ua.Positioning.Client` provides high-level +clients for OPC UA Relative Spatial Location (OPC 10000-210) and Global +Positioning (OPC 10000-211). + +The package composes the source-generated proxies with continuation-safe +discovery, typed reads, subscriptions, frame-chain resolution, Zone +transformation, and dependency-injection registration. + +Use `AddPositioningClient()` over the managed-session registration or construct +`RelativeSpatialLocationClient` and `GlobalPositioningClient` directly. The +clients decode generated structured values without boxed APIs and expose +`IStreamingSubscription`-based position, location, and NodeVersion streams. diff --git a/src/Opc.Ua.Positioning.Client/Opc.Ua.Positioning.Client.csproj b/src/Opc.Ua.Positioning.Client/Opc.Ua.Positioning.Client.csproj new file mode 100644 index 0000000000..d610c8f5c9 --- /dev/null +++ b/src/Opc.Ua.Positioning.Client/Opc.Ua.Positioning.Client.csproj @@ -0,0 +1,30 @@ + + + $(AssemblyPrefix).Positioning.Client + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.Positioning.Client + Opc.Ua.Positioning.Client + $(NoWarn);CS1591 + enable + Client APIs for OPC UA Relative Spatial Location and Global Positioning. + true + NugetREADME.md + MIT + + true + true + + + + + + $(PackageId).Debug + + + + + + + + + diff --git a/src/Opc.Ua.Positioning.Client/PositioningClientOperations.cs b/src/Opc.Ua.Positioning.Client/PositioningClientOperations.cs new file mode 100644 index 0000000000..d0d0d7658b --- /dev/null +++ b/src/Opc.Ua.Positioning.Client/PositioningClientOperations.cs @@ -0,0 +1,230 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; + +namespace Opc.Ua.Positioning.Client +{ + internal sealed class PositioningClientOperations + { + public PositioningClientOperations( + ISession session, + ITelemetryContext telemetry) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + Telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + } + + public ISession Session { get; } + + public ITelemetryContext Telemetry { get; } + + public async ValueTask> BrowseAsync( + NodeId nodeId, + NodeId referenceTypeId, + uint nodeClassMask, + CancellationToken cancellationToken) + { + ( + ArrayOf> results, + ArrayOf errors + ) = await Session.ManagedBrowseAsync( + requestHeader: null, + view: null, + nodesToBrowse: [nodeId], + maxResultsToReturn: 0, + browseDirection: BrowseDirection.Forward, + referenceTypeId: referenceTypeId, + includeSubtypes: true, + nodeClassMask: nodeClassMask, + ct: cancellationToken).ConfigureAwait(false); + + if (errors.Count > 0 && ServiceResult.IsBad(errors[0])) + { + throw new ServiceResultException(errors[0]); + } + + return results.Count > 0 + ? results[0] + : default; + } + + public async ValueTask ResolveChildAsync( + NodeId parentId, + NodeId referenceTypeId, + QualifiedName browseName, + CancellationToken cancellationToken) + { + var path = new BrowsePath + { + StartingNode = parentId, + RelativePath = new RelativePath + { + Elements = + [ + new RelativePathElement + { + ReferenceTypeId = referenceTypeId, + IsInverse = false, + IncludeSubtypes = true, + TargetName = browseName + } + ] + } + }; + + TranslateBrowsePathsToNodeIdsResponse response = await Session + .TranslateBrowsePathsToNodeIdsAsync( + requestHeader: null, + browsePaths: new[] { path }.ToArrayOf(), + ct: cancellationToken).ConfigureAwait(false); + + if (response.Results.Count == 0 || + StatusCode.IsBad(response.Results[0].StatusCode) || + response.Results[0].Targets.Count == 0) + { + return NodeId.Null; + } + + return ExpandedNodeId.ToNodeId( + response.Results[0].Targets[0].TargetId, + Session.NamespaceUris); + } + + public async ValueTask ResolveRequiredChildAsync( + NodeId parentId, + NodeId referenceTypeId, + QualifiedName browseName, + CancellationToken cancellationToken) + { + NodeId nodeId = await ResolveChildAsync( + parentId, + referenceTypeId, + browseName, + cancellationToken).ConfigureAwait(false); + if (nodeId.IsNull) + { + throw new ServiceResultException( + StatusCodes.BadNoMatch, + $"Node '{parentId}' does not expose '{browseName}'."); + } + return nodeId; + } + + public async ValueTask ReadStructureAsync( + NodeId nodeId, + CancellationToken cancellationToken) + where T : class, IEncodeable + { + DataValue dataValue = await Session.ReadValueAsync( + nodeId, + cancellationToken).ConfigureAwait(false); + T value; + if (!dataValue.WrappedValue.TryGetValue( + out value!, + Session.MessageContext)) + { + throw new ServiceResultException( + StatusCodes.BadTypeMismatch, + $"Node '{nodeId}' does not contain a {typeof(T).Name} value."); + } + return value; + } + + public async ValueTask> ReadStructureArrayAsync( + NodeId nodeId, + CancellationToken cancellationToken) + where T : class, IEncodeable + { + DataValue dataValue = await Session.ReadValueAsync( + nodeId, + cancellationToken).ConfigureAwait(false); + if (!dataValue.WrappedValue.TryGetValue( + out ArrayOf value, + Session.MessageContext)) + { + throw new ServiceResultException( + StatusCodes.BadTypeMismatch, + $"Node '{nodeId}' does not contain an array of {typeof(T).Name} values."); + } + return value; + } + + public async ValueTask ReadNodeIdAsync( + NodeId nodeId, + CancellationToken cancellationToken) + { + DataValue dataValue = await Session.ReadValueAsync( + nodeId, + cancellationToken).ConfigureAwait(false); + if (!dataValue.WrappedValue.TryGetValue(out NodeId value)) + { + throw new ServiceResultException( + StatusCodes.BadTypeMismatch, + $"Node '{nodeId}' does not contain a NodeId value."); + } + return value; + } + + public async ValueTask ReadUInt32Async( + NodeId nodeId, + CancellationToken cancellationToken) + { + DataValue dataValue = await Session.ReadValueAsync( + nodeId, + cancellationToken).ConfigureAwait(false); + if (!dataValue.WrappedValue.TryGetValue(out uint value)) + { + throw new ServiceResultException( + StatusCodes.BadTypeMismatch, + $"Node '{nodeId}' does not contain a UInt32 value."); + } + return value; + } + + public PositioningObjectEntry CreateEntry( + ReferenceDescription reference) + { + var nodeId = ExpandedNodeId.ToNodeId( + reference.NodeId, + Session.NamespaceUris); + var typeDefinitionId = ExpandedNodeId.ToNodeId( + reference.TypeDefinition, + Session.NamespaceUris); + return new PositioningObjectEntry( + nodeId, + reference.BrowseName, + reference.DisplayName, + typeDefinitionId); + } + } +} diff --git a/src/Opc.Ua.Positioning.Client/PositioningEntries.cs b/src/Opc.Ua.Positioning.Client/PositioningEntries.cs new file mode 100644 index 0000000000..d6ae934c86 --- /dev/null +++ b/src/Opc.Ua.Positioning.Client/PositioningEntries.cs @@ -0,0 +1,91 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Gpos; + +namespace Opc.Ua.Positioning.Client +{ + /// + /// A discovered Positioning object. + /// + public sealed record PositioningObjectEntry( + NodeId NodeId, + QualifiedName BrowseName, + LocalizedText DisplayName, + NodeId TypeDefinitionId); + + /// + /// A typed RSL frame value and its Base relationship. + /// + public sealed record RelativeSpatialFrameValue( + NodeId NodeId, + NodeId BaseNodeId, + ThreeDFrame Frame, + StatusCode StatusCode, + DateTimeUtc SourceTimestamp); + + /// + /// An RSL frame resolved through its Base chain to the world frame. + /// + public sealed record ResolvedRelativeSpatialFrame( + NodeId NodeId, + RslFrameTransform TransformToWorld, + ArrayOf FrameChain); + + /// + /// A change to an RSL spatial list NodeVersion. + /// + public sealed record PositioningNodeVersionChange( + NodeId ListNodeId, + string NodeVersion, + StatusCode StatusCode, + DateTimeUtc SourceTimestamp); + + /// + /// A typed GPOS global position value and its projection metadata. + /// + public sealed record GlobalPositionValue( + NodeId NodeId, + GlobalPositionDataType Position, + NodeId SourceNodeId, + uint CoordinateReferenceSystem, + StatusCode StatusCode, + DateTimeUtc SourceTimestamp); + + /// + /// A typed GPOS global location value and its projection metadata. + /// + public sealed record GlobalLocationValue( + NodeId NodeId, + GlobalLocationDataType Location, + NodeId SourceNodeId, + uint CoordinateReferenceSystem, + StatusCode StatusCode, + DateTimeUtc SourceTimestamp); +} diff --git a/src/Opc.Ua.Positioning.Client/Properties/AssemblyInfo.cs b/src/Opc.Ua.Positioning.Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.Positioning.Client/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.Positioning.Client/RelativeSpatialLocationClient.cs b/src/Opc.Ua.Positioning.Client/RelativeSpatialLocationClient.cs new file mode 100644 index 0000000000..a3a2f210e7 --- /dev/null +++ b/src/Opc.Ua.Positioning.Client/RelativeSpatialLocationClient.cs @@ -0,0 +1,455 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; +using Opc.Ua.Client.Subscriptions; +using Opc.Ua.Client.Subscriptions.Streaming; +using Opc.Ua.Rsl; +using MonitoringOptions = + Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions; + +namespace Opc.Ua.Positioning.Client +{ + /// + /// High-level client for the OPC 10000-210 Relative Spatial Location model. + /// + public sealed class RelativeSpatialLocationClient + { + private readonly PositioningClientOperations m_operations; + + /// + /// Creates an RSL client. + /// + public RelativeSpatialLocationClient( + ISession session, + ITelemetryContext telemetry) + { + m_operations = new PositioningClientOperations(session, telemetry); + } + + /// + /// OPC UA session used by this client. + /// + public ISession Session => m_operations.Session; + + /// + /// Telemetry context used by generated proxies. + /// + public ITelemetryContext Telemetry => m_operations.Telemetry; + + /// + /// Standardized RelativeSpatialLocations entry point. + /// + public NodeId RelativeSpatialLocationsId => NodeId.Create( + Rsl.Objects.RelativeSpatialLocations, + Rsl.Namespaces.RSL, + Session.NamespaceUris); + + /// + /// Enumerates all spatial object lists from the standardized entry point. + /// + public async IAsyncEnumerable + EnumerateSpatialObjectListsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArrayOf references = await m_operations.BrowseAsync( + RelativeSpatialLocationsId, + ReferenceTypeIds.Organizes, + (uint)NodeClass.Object, + cancellationToken).ConfigureAwait(false); + + ExpandedNodeId expectedType = Rsl.ObjectTypeIds + .SpatialObjectsListType; + for (int i = 0; i < references.Count; i++) + { + ReferenceDescription reference = references[i]; + if (await Session.NodeCache.IsTypeOfAsync( + reference.TypeDefinition, + expectedType, + cancellationToken).ConfigureAwait(false)) + { + yield return m_operations.CreateEntry(reference); + } + } + } + + /// + /// Enumerates spatial objects organized by a spatial object list. + /// + public async IAsyncEnumerable + EnumerateSpatialObjectsAsync( + NodeId listId, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArrayOf references = await m_operations.BrowseAsync( + listId, + ReferenceTypeIds.Organizes, + (uint)NodeClass.Object, + cancellationToken).ConfigureAwait(false); + + ExpandedNodeId expectedType = Rsl.ObjectTypeIds.SpatialObjectType; + for (int i = 0; i < references.Count; i++) + { + ReferenceDescription reference = references[i]; + if (await Session.NodeCache.IsTypeOfAsync( + reference.TypeDefinition, + expectedType, + cancellationToken).ConfigureAwait(false)) + { + yield return m_operations.CreateEntry(reference); + } + } + } + + /// + /// Opens the generated proxy for a spatial object. + /// + public SpatialObjectTypeClient OpenSpatialObject(NodeId nodeId) + { + return new SpatialObjectTypeClient(Session, nodeId, Telemetry); + } + + /// + /// Opens the generated proxy for a spatial object list. + /// + public SpatialObjectsListTypeClient OpenSpatialObjectList(NodeId nodeId) + { + return new SpatialObjectsListTypeClient(Session, nodeId, Telemetry); + } + + /// + /// Enumerates spatial frames below an RSL frame folder. + /// + public async IAsyncEnumerable EnumerateFramesAsync( + NodeId folderId, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArrayOf references = await m_operations.BrowseAsync( + folderId, + ReferenceTypeIds.HasComponent, + (uint)NodeClass.Variable, + cancellationToken).ConfigureAwait(false); + + ExpandedNodeId expectedType = Rsl.VariableTypeIds.SpatialLocationType; + for (int i = 0; i < references.Count; i++) + { + ReferenceDescription reference = references[i]; + if (await Session.NodeCache.IsTypeOfAsync( + reference.TypeDefinition, + expectedType, + cancellationToken).ConfigureAwait(false)) + { + yield return m_operations.CreateEntry(reference); + } + } + } + + /// + /// Reads the mandatory PositionFrame and its Base NodeId. + /// + public async ValueTask ReadPositionFrameAsync( + NodeId spatialObjectId, + CancellationToken cancellationToken = default) + { + ushort namespaceIndex = Session.NamespaceUris.GetIndexOrAppend( + Rsl.Namespaces.RSL); + NodeId frameId = await m_operations.ResolveRequiredChildAsync( + spatialObjectId, + ReferenceTypeIds.HasComponent, + new QualifiedName("PositionFrame", namespaceIndex), + cancellationToken).ConfigureAwait(false); + return await ReadFrameAsync( + frameId, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads an RSL frame Variable and its Base NodeId. + /// + /// + public async ValueTask ReadFrameAsync( + NodeId frameId, + CancellationToken cancellationToken = default) + { + ushort namespaceIndex = Session.NamespaceUris.GetIndexOrAppend( + Rsl.Namespaces.RSL); + NodeId baseId = await m_operations.ResolveRequiredChildAsync( + frameId, + ReferenceTypeIds.HasComponent, + new QualifiedName("Base", namespaceIndex), + cancellationToken).ConfigureAwait(false); + + DataValue frameValue = await Session.ReadValueAsync( + frameId, + cancellationToken).ConfigureAwait(false); + ThreeDFrame frame; + if (!frameValue.WrappedValue.TryGetValue( + out frame!, + Session.MessageContext)) + { + throw new ServiceResultException( + StatusCodes.BadTypeMismatch, + "PositionFrame does not contain a 3DFrame value."); + } + + NodeId frameBase = await m_operations.ReadNodeIdAsync( + baseId, + cancellationToken).ConfigureAwait(false); + + return new RelativeSpatialFrameValue( + frameId, + frameBase, + frame, + frameValue.StatusCode, + frameValue.SourceTimestamp); + } + + /// + /// Resolves a frame through its Base chain to the world coordinate system. + /// + /// + /// + public async ValueTask ResolveFrameToWorldAsync( + NodeId frameId, + AngleUnit angleUnit, + CancellationToken cancellationToken = default) + { + if (frameId.IsNull) + { + throw new ArgumentException( + "A non-null frame NodeId is required.", + nameof(frameId)); + } + + var visited = new HashSet(); + var chain = new List(); + RslFrameTransform transform = RslFrameTransform.Identity; + NodeId current = frameId; + + while (!current.IsNull) + { + if (!visited.Add(current)) + { + throw new ServiceResultException( + StatusCodes.BadInvalidState, + $"The RSL Base chain contains a cycle at '{current}'."); + } + if (chain.Count >= 1024) + { + throw new ServiceResultException( + StatusCodes.BadEncodingLimitsExceeded, + "The RSL Base chain exceeds 1024 frames."); + } + + RelativeSpatialFrameValue value = await ReadFrameAsync( + current, + cancellationToken).ConfigureAwait(false); + if (StatusCode.IsBad(value.StatusCode)) + { + throw new ServiceResultException( + value.StatusCode, + $"RSL frame '{current}' has a bad status."); + } + + var local = RslFrameTransform.FromFrame( + value.Frame, + angleUnit); + transform = local.Compose(transform); + chain.Add(current); + current = value.BaseNodeId; + } + + return new ResolvedRelativeSpatialFrame( + frameId, + transform, + chain.ToArray().ToArrayOf()); + } + + /// + /// Streams changes to an RSL frame Variable. + /// + public IAsyncEnumerable ObserveFrameAsync( + NodeId frameId, + IStreamingSubscription streaming, + MonitoringOptions? options = null, + CancellationToken cancellationToken = default) + { + streaming.ThrowIfNull(nameof(streaming)); + return ObserveFrameImplAsync( + frameId, + streaming, + options, + cancellationToken); + } + + /// + /// Streams changes to a spatial object's mandatory PositionFrame. + /// + public IAsyncEnumerable + ObservePositionFrameAsync( + NodeId spatialObjectId, + IStreamingSubscription streaming, + MonitoringOptions? options = null, + CancellationToken cancellationToken = default) + { + streaming.ThrowIfNull(nameof(streaming)); + return ObservePositionFrameImplAsync( + spatialObjectId, + streaming, + options, + cancellationToken); + } + + private async IAsyncEnumerable + ObservePositionFrameImplAsync( + NodeId spatialObjectId, + IStreamingSubscription streaming, + MonitoringOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + ushort namespaceIndex = Session.NamespaceUris.GetIndexOrAppend( + Rsl.Namespaces.RSL); + NodeId frameId = await m_operations.ResolveRequiredChildAsync( + spatialObjectId, + ReferenceTypeIds.HasComponent, + new QualifiedName("PositionFrame", namespaceIndex), + cancellationToken).ConfigureAwait(false); + await foreach (RelativeSpatialFrameValue value in ObserveFrameImplAsync( + frameId, + streaming, + options, + cancellationToken).ConfigureAwait(false)) + { + yield return value; + } + } + + /// + /// Streams changes to an RSL spatial object's list NodeVersion. + /// + public IAsyncEnumerable + ObserveNodeVersionAsync( + NodeId listId, + IStreamingSubscription streaming, + MonitoringOptions? options = null, + CancellationToken cancellationToken = default) + { + streaming.ThrowIfNull(nameof(streaming)); + return ObserveNodeVersionImplAsync( + listId, + streaming, + options, + cancellationToken); + } + + private async IAsyncEnumerable + ObserveNodeVersionImplAsync( + NodeId listId, + IStreamingSubscription streaming, + MonitoringOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + NodeId nodeVersionId = await m_operations.ResolveRequiredChildAsync( + listId, + ReferenceTypeIds.HasProperty, + QualifiedName.From(BrowseNames.NodeVersion), + cancellationToken).ConfigureAwait(false); + + await foreach (DataValueChange change in streaming + .SubscribeDataChangesAsync( + nodeVersionId, + options, + cancellationToken) + .ConfigureAwait(false)) + { + string nodeVersion = null!; + if (!change.Value.WrappedValue.TryGetValue( + out nodeVersion)) + { + throw new ServiceResultException( + StatusCodes.BadTypeMismatch, + "NodeVersion does not contain a String value."); + } + yield return new PositioningNodeVersionChange( + listId, + nodeVersion, + change.Value.StatusCode, + change.Value.SourceTimestamp); + } + } + + private async IAsyncEnumerable + ObserveFrameImplAsync( + NodeId frameId, + IStreamingSubscription streaming, + MonitoringOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + ushort namespaceIndex = Session.NamespaceUris.GetIndexOrAppend( + Rsl.Namespaces.RSL); + NodeId baseId = await m_operations.ResolveRequiredChildAsync( + frameId, + ReferenceTypeIds.HasComponent, + new QualifiedName("Base", namespaceIndex), + cancellationToken).ConfigureAwait(false); + + await foreach (DataValueChange change in streaming + .SubscribeDataChangesAsync( + frameId, + options, + cancellationToken) + .ConfigureAwait(false)) + { + ThreeDFrame frame = null!; + if (!change.Value.WrappedValue.TryGetValue( + out frame!, + Session.MessageContext)) + { + throw new ServiceResultException( + StatusCodes.BadTypeMismatch, + "The monitored frame does not contain a 3DFrame value."); + } + NodeId baseNodeId = await m_operations.ReadNodeIdAsync( + baseId, + cancellationToken).ConfigureAwait(false); + yield return new RelativeSpatialFrameValue( + frameId, + baseNodeId, + frame, + change.Value.StatusCode, + change.Value.SourceTimestamp); + } + } + } +} diff --git a/src/Opc.Ua.Positioning.Server/EventIds.cs b/src/Opc.Ua.Positioning.Server/EventIds.cs new file mode 100644 index 0000000000..4903234ed2 --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/EventIds.cs @@ -0,0 +1,38 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Positioning.Server +{ + internal static class PositioningServerEventIds + { + public const int NodeManagerReady = 7900; + public const int GlobalProviderFailed = 7901; + public const int RelativeProviderFailed = 7902; + } +} diff --git a/src/Opc.Ua.Positioning.Server/Hosting/IPositioningServerBuilder.cs b/src/Opc.Ua.Positioning.Server/Hosting/IPositioningServerBuilder.cs new file mode 100644 index 0000000000..d03234f9d0 --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/Hosting/IPositioningServerBuilder.cs @@ -0,0 +1,74 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; + +namespace Opc.Ua.Positioning.Server.Hosting +{ + /// + /// Fluent registration surface for Positioning providers and configurators. + /// + public interface IPositioningServerBuilder + { + /// + /// Underlying service collection. + /// + IServiceCollection Services { get; } + + /// + /// Registers a geo location provider. + /// + /// Provider implementation type. + IPositioningServerBuilder AddGeoLocationProvider< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() + where T : class, IGeoLocationProvider; + + /// + /// Registers a geo location provider factory. + /// + IPositioningServerBuilder AddGeoLocationProvider( + Func factory); + + /// + /// Registers a relative spatial location provider. + /// + /// Provider implementation type. + IPositioningServerBuilder AddRelativeSpatialLocationProvider< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() + where T : class, IRelativeSpatialLocationProvider; + + /// + /// Registers a relative spatial location provider factory. + /// + IPositioningServerBuilder AddRelativeSpatialLocationProvider( + Func factory); + } +} diff --git a/src/Opc.Ua.Positioning.Server/Hosting/OpcUaPositioningServerBuilderExtensions.cs b/src/Opc.Ua.Positioning.Server/Hosting/OpcUaPositioningServerBuilderExtensions.cs new file mode 100644 index 0000000000..379c0868e9 --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/Hosting/OpcUaPositioningServerBuilderExtensions.cs @@ -0,0 +1,179 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Opc.Ua; +using Opc.Ua.Positioning.Server; +using Opc.Ua.Positioning.Server.Hosting; +using Opc.Ua.Server; +using Opc.Ua.Server.Hosting; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Hosting extensions for OPC UA Positioning servers. + /// + public static class OpcUaPositioningServerBuilderExtensions + { + /// + /// Registers the standalone Positioning node manager. + /// + public static IPositioningServerBuilder AddPositioningServer( + this IOpcUaServerBuilder builder) + { + builder.ThrowIfNull(nameof(builder)); + RegisterCommonServices(builder.Services); + EnsureStandaloneManagerNotRegistered(builder.Services); + builder.Services.AddSingleton(); + builder.AddNodeManager(); + return new PositioningServerBuilder(builder.Services); + } + + /// + /// Registers Positioning services for an existing composite node manager. + /// + /// Composite node manager type. + public static IPositioningServerBuilder AddPositioningFor< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TNodeManager>( + this IOpcUaServerBuilder builder) + where TNodeManager : AsyncCustomNodeManager + { + builder.ThrowIfNull(nameof(builder)); + RegisterCommonServices(builder.Services); + return new PositioningServerBuilder(builder.Services); + } + + /// + /// Registers an asynchronous Positioning configuration callback. + /// + /// Target node manager type. + public static IPositioningServerBuilder ConfigurePositioningFor( + this IPositioningServerBuilder builder, + Func configure) + where TNodeManager : AsyncCustomNodeManager + { + builder.ThrowIfNull(nameof(builder)); + configure.ThrowIfNull(nameof(configure)); + builder.Services.AddSingleton( + new DelegatePositioningConfigurator( + typeof(TNodeManager), + configure)); + return builder; + } + + private static void RegisterCommonServices(IServiceCollection services) + { + services.TryAddSingleton(); + } + + private static void EnsureStandaloneManagerNotRegistered( + IServiceCollection services) + { + foreach (ServiceDescriptor descriptor in services) + { + if (descriptor.ServiceType == + typeof(PositioningNodeManagerRegistrationMarker)) + { + throw new InvalidOperationException( + "AddPositioningServer has already been called."); + } + } + } + + private sealed class PositioningServerBuilder : IPositioningServerBuilder + { + public PositioningServerBuilder(IServiceCollection services) + { + Services = services; + } + + public IServiceCollection Services { get; } + + public IPositioningServerBuilder AddGeoLocationProvider< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() + where T : class, IGeoLocationProvider + { + Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + return this; + } + + public IPositioningServerBuilder AddGeoLocationProvider( + Func factory) + { + factory.ThrowIfNull(nameof(factory)); + Services.AddSingleton(factory); + return this; + } + + public IPositioningServerBuilder AddRelativeSpatialLocationProvider< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() + where T : class, IRelativeSpatialLocationProvider + { + Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + return this; + } + + public IPositioningServerBuilder AddRelativeSpatialLocationProvider( + Func factory) + { + factory.ThrowIfNull(nameof(factory)); + Services.AddSingleton(factory); + return this; + } + } + + private sealed class DelegatePositioningConfigurator : + IPositioningPostSetupConfigurator + { + private readonly Func m_configure; + + public DelegatePositioningConfigurator( + Type targetManagerType, + Func configure) + { + TargetManagerType = targetManagerType; + m_configure = configure; + } + + public Type TargetManagerType { get; } + + public ValueTask RunAsync(PositioningServerContext context) + { + return m_configure(context); + } + } + + private sealed class PositioningNodeManagerRegistrationMarker; + } +} diff --git a/src/Opc.Ua.Positioning.Server/Hosting/PositioningPostSetup.cs b/src/Opc.Ua.Positioning.Server/Hosting/PositioningPostSetup.cs new file mode 100644 index 0000000000..f186bace95 --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/Hosting/PositioningPostSetup.cs @@ -0,0 +1,241 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server; + +namespace Opc.Ua.Positioning.Server.Hosting +{ + /// + /// Context supplied to Positioning post-setup configurators. + /// + public sealed class PositioningServerContext + { + internal PositioningServerContext( + AsyncCustomNodeManager manager, + ArrayOf geoLocationProviders, + ArrayOf relativeProviders, + CancellationToken cancellationToken) + { + Manager = manager; + AddressSpace = new PositioningAddressSpaceBuilder(manager); + GeoLocationProviders = geoLocationProviders; + RelativeSpatialLocationProviders = relativeProviders; + CancellationToken = cancellationToken; + } + + /// + /// Owning node manager. + /// + public AsyncCustomNodeManager Manager { get; } + + /// + /// Address-space builder bound to . + /// + public PositioningAddressSpaceBuilder AddressSpace { get; } + + /// + /// Registered geo location providers. + /// + public ArrayOf GeoLocationProviders { get; } + + /// + /// Registered relative spatial location providers. + /// + public ArrayOf RelativeSpatialLocationProviders { get; } + + /// + /// Startup cancellation token. + /// + public CancellationToken CancellationToken { get; } + } + + /// + /// Dispatches registered Positioning configurators to compatible node managers. + /// + public interface IPositioningPostSetupRunner + { + /// + /// Runs configurators registered for . + /// + ValueTask RunAsync( + AsyncCustomNodeManager manager, + CancellationToken cancellationToken); + } + + internal interface IPositioningPostSetupConfigurator + { + Type TargetManagerType { get; } + + ValueTask RunAsync(PositioningServerContext context); + } + + internal sealed class PositioningPostSetupRunner : IPositioningPostSetupRunner + { + private readonly ArrayOf m_configurators; + private readonly ArrayOf m_geoLocationProviders; + private readonly ArrayOf m_relativeProviders; + + public PositioningPostSetupRunner( + IEnumerable configurators, + IEnumerable geoLocationProviders, + IEnumerable relativeProviders) + { + m_configurators = configurators.ToArray().ToArrayOf(); + m_geoLocationProviders = geoLocationProviders.ToArray().ToArrayOf(); + m_relativeProviders = relativeProviders.ToArray().ToArrayOf(); + } + + public async ValueTask RunAsync( + AsyncCustomNodeManager manager, + CancellationToken cancellationToken) + { + manager.ThrowIfNull(nameof(manager)); + await PositioningNamespaceMetadata.EnsureAsync( + manager, + cancellationToken).ConfigureAwait(false); + var context = new PositioningServerContext( + manager, + m_geoLocationProviders, + m_relativeProviders, + cancellationToken); + + for (int i = 0; i < m_configurators.Count; i++) + { + IPositioningPostSetupConfigurator configurator = m_configurators[i]; + if (configurator.TargetManagerType.IsAssignableFrom(manager.GetType())) + { + await configurator.RunAsync(context).ConfigureAwait(false); + } + } + } + } + + internal static class PositioningNamespaceMetadata + { + private static readonly ConditionalWeakTable< + IServerInternal, + HashSet> s_linkedMetadata = new(); + private static readonly Lock s_linkedMetadataLock = new(); + + public static async ValueTask EnsureAsync( + AsyncCustomNodeManager manager, + CancellationToken cancellationToken) + { + IConfigurationNodeManager? configurationNodeManager = + manager.Server.NodeManager.ConfigurationNodeManager; + if (configurationNodeManager == null) + { + return; + } + + await EnsureAsync( + manager, + configurationNodeManager, + Opc.Ua.Rsl.Namespaces.RSL, + "1.00.1", + new DateTime(2023, 1, 12, 0, 0, 0, DateTimeKind.Utc), + cancellationToken).ConfigureAwait(false); + await EnsureAsync( + manager, + configurationNodeManager, + Opc.Ua.Gpos.Namespaces.GPOS, + "1.0.0", + new DateTime(2025, 9, 25, 0, 0, 0, DateTimeKind.Utc), + cancellationToken).ConfigureAwait(false); + } + + private static async ValueTask EnsureAsync( + AsyncCustomNodeManager manager, + IConfigurationNodeManager configurationNodeManager, + string namespaceUri, + string namespaceVersion, + DateTime publicationDate, + CancellationToken cancellationToken) + { + NamespaceMetadataState metadata = + await configurationNodeManager.CreateNamespaceMetadataStateAsync( + namespaceUri, + cancellationToken).ConfigureAwait(false); + ushort namespaceIndex = (ushort)manager.Server.NamespaceUris.GetIndex( + namespaceUri); + metadata.BrowseName = new QualifiedName( + namespaceUri, + namespaceIndex); + metadata.DisplayName = LocalizedText.From(namespaceUri); + metadata.NamespaceUri!.Value = namespaceUri; + metadata.NamespaceVersion!.Value = namespaceVersion; + metadata.NamespacePublicationDate!.Value = + new DateTimeUtc(publicationDate); + metadata.IsNamespaceSubset!.Value = false; + if (MarkMetadataLinked(manager.Server, metadata.NodeId)) + { + if (!metadata.ReferenceExists( + ReferenceTypeIds.HasComponent, + isInverse: true, + ObjectIds.Server_Namespaces)) + { + metadata.AddReference( + ReferenceTypeIds.HasComponent, + isInverse: true, + ObjectIds.Server_Namespaces); + } + await manager.Server.NodeManager.AddReferencesAsync( + ObjectIds.Server_Namespaces, + new List + { + new NodeStateReference( + ReferenceTypeIds.HasComponent, + isInverse: false, + metadata.NodeId) + }, + cancellationToken).ConfigureAwait(false); + } + await metadata.ClearChangeMasksAsync( + manager.SystemContext, + includeChildren: true, + cancellationToken).ConfigureAwait(false); + } + + private static bool MarkMetadataLinked( + IServerInternal server, + NodeId metadataId) + { + lock (s_linkedMetadataLock) + { + return s_linkedMetadata.GetOrCreateValue(server).Add(metadataId); + } + } + } +} diff --git a/src/Opc.Ua.Positioning.Server/NugetREADME.md b/src/Opc.Ua.Positioning.Server/NugetREADME.md new file mode 100644 index 0000000000..2c07dca7d6 --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/NugetREADME.md @@ -0,0 +1,19 @@ +# OPC UA .NET Standard โ€” Positioning Server + +`OPCFoundation.NetStandard.Opc.Ua.Positioning.Server` hosts the OPC UA +Relative Spatial Location (OPC 10000-210) and Global Positioning +(OPC 10000-211) models. + +It provides a reusable node manager, address-space builders, provider +contracts, dependency-injection integration, and a composition path for +servers that already own a companion node manager. + +The package depends on `OPCFoundation.NetStandard.Opc.Ua.Positioning` for the +source-generated model and transformation primitives. + +Use `AddPositioningServer()` for standalone hosting or +`AddPositioningFor()` to compose RSL/GPOS into an existing +companion node manager. `PositioningAddressSpaceBuilder` creates spatial +lists, SpatialObject AddIns, frames, Zones, GlobalPosition Variables, and +GlobalLocation Variables. Technology-neutral global and relative providers +publish cancellation-aware streams with quality and timestamps. diff --git a/src/Opc.Ua.Positioning.Server/Opc.Ua.Positioning.Server.csproj b/src/Opc.Ua.Positioning.Server/Opc.Ua.Positioning.Server.csproj new file mode 100644 index 0000000000..8255cc76f1 --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/Opc.Ua.Positioning.Server.csproj @@ -0,0 +1,32 @@ + + + $(AssemblyPrefix).Positioning.Server + $(DefineConstants);OPCUA_INCLUDE_ASYNC + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.Positioning.Server + Opc.Ua.Positioning.Server + $(NoWarn);CS1591 + enable + Server APIs, providers, builders, and hosting integration for OPC UA Relative Spatial Location and Global Positioning. + true + NugetREADME.md + MIT + + true + true + + + + + + + $(PackageId).Debug + + + + + + + + + diff --git a/src/Opc.Ua.Positioning.Server/PositioningAddressSpaceBuilder.cs b/src/Opc.Ua.Positioning.Server/PositioningAddressSpaceBuilder.cs new file mode 100644 index 0000000000..edb4456d34 --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/PositioningAddressSpaceBuilder.cs @@ -0,0 +1,1599 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua.Gpos; +using Opc.Ua.Rsl; +using Opc.Ua.Server; +using Opc.Ua.Server.NodeManager; + +namespace Opc.Ua.Positioning.Server +{ + /// + /// Creates RSL and GPOS instances in any . + /// + public sealed class PositioningAddressSpaceBuilder + { + /// + /// How often a provider that cannot push is polled when the bound + /// Variable does not declare a sampling interval of its own. + /// + private const double kDefaultPollIntervalMilliseconds = 1000.0; + + private readonly ILogger m_logger; + + private readonly Dictionary + m_spatialObjectLists = []; + + private readonly Dictionary + m_pendingModelChanges = []; + + /// + /// Creates a builder for . + /// + public PositioningAddressSpaceBuilder(AsyncCustomNodeManager nodeManager) + { + NodeManager = nodeManager ?? + throw new ArgumentNullException(nameof(nodeManager)); + m_logger = nodeManager.Server.Telemetry + .CreateLogger(); + } + + /// + /// Owning node manager. + /// + public AsyncCustomNodeManager NodeManager { get; } + + /// + /// System context used to create model instances. + /// + public ServerSystemContext SystemContext => NodeManager.SystemContext; + + /// + /// Registers a completed node subtree with the owning node manager. + /// + public async ValueTask RegisterAsync( + NodeState node, + CancellationToken cancellationToken = default) + { + node.ThrowIfNull(nameof(node)); + await NodeManager.AddPredefinedNodeAsync( + node, + cancellationToken).ConfigureAwait(false); + SynchronizeFrameSubtree(node); + + if (m_pendingModelChanges.TryGetValue( + node, + out SpatialObjectsListState? list)) + { + m_pendingModelChanges.Remove(node); + IncrementNodeVersion(list); + ReportModelChange(list, ModelChangeVerbs.ReferenceAdded); + } + } + + /// + /// Creates a spatial objects list and links it from the standardized RSL entry point. + /// + /// + public SpatialObjectsListState CreateSpatialObjectsList( + NodeState owner, + QualifiedName browseName, + string identifier, + ThreeDFrame worldFrame, + EUInformation lengthUnit, + EUInformation angleUnit) + { + owner.ThrowIfNull(nameof(owner)); + if (string.IsNullOrWhiteSpace(identifier)) + { + throw new ArgumentException( + "A stable list identifier is required.", + nameof(identifier)); + } + worldFrame.ThrowIfNull(nameof(worldFrame)); + lengthUnit.ThrowIfNull(nameof(lengthUnit)); + angleUnit.ThrowIfNull(nameof(angleUnit)); + ValidateFrame(worldFrame, nameof(worldFrame)); + EnsureUniqueChild(owner, browseName); + + SpatialObjectsListState list = SystemContext + .CreateInstanceOfSpatialObjectsListType(owner, browseName); + list.ReferenceTypeId = ReferenceTypeIds.HasAddIn; + owner.AddChild(list); + list.Identifier!.Value = identifier; + list.NodeVersion!.Value = "1"; + + CartesianFrameAngleOrientationState concreteWorld = CreateCartesianFrame( + list, + new QualifiedName("WorldFrame", RslNamespaceIndex), + NodeId.Null, + worldFrame, + lengthUnit, + angleUnit); + list.CreateOrReplaceWorldFrame(SystemContext, concreteWorld); + + FolderState root = GetRelativeSpatialLocations(); + root.AddReference(ReferenceTypeIds.Organizes, false, list.NodeId); + list.AddReference(ReferenceTypeIds.Organizes, true, root.NodeId); + return list; + } + + /// + /// Attaches a spatial-object AddIn to an existing object and organizes it from a list. + /// + public SpatialObjectState AttachSpatialObject( + NodeState owner, + SpatialObjectsListState list, + QualifiedName browseName, + string? identifier, + ThreeDFrame positionFrame, + EUInformation lengthUnit, + EUInformation angleUnit) + { + owner.ThrowIfNull(nameof(owner)); + list.ThrowIfNull(nameof(list)); + positionFrame.ThrowIfNull(nameof(positionFrame)); + lengthUnit.ThrowIfNull(nameof(lengthUnit)); + angleUnit.ThrowIfNull(nameof(angleUnit)); + ValidateFrame(positionFrame, nameof(positionFrame)); + EnsureUniqueChild(owner, browseName); + + SpatialObjectState spatialObject = SystemContext + .CreateInstanceOfSpatialObjectType(owner, browseName); + spatialObject.ReferenceTypeId = ReferenceTypeIds.HasAddIn; + owner.AddChild(spatialObject); + + if (!string.IsNullOrWhiteSpace(identifier)) + { + spatialObject.AddIdentifier(SystemContext); + spatialObject.Identifier!.Value = identifier; + } + + CartesianFrameAngleOrientationState concreteFrame = CreateCartesianFrame( + spatialObject, + new QualifiedName("PositionFrame", RslNamespaceIndex), + list.WorldFrame!.NodeId, + positionFrame, + lengthUnit, + angleUnit); + spatialObject.CreateOrReplacePositionFrame(SystemContext, concreteFrame); + + list.AddReference(ReferenceTypeIds.Organizes, false, spatialObject.NodeId); + spatialObject.AddReference(ReferenceTypeIds.Organizes, true, list.NodeId); + m_spatialObjectLists[spatialObject] = list; + m_pendingModelChanges[spatialObject] = list; + return spatialObject; + } + + /// + /// Adds a concrete Cartesian attach-point frame below a spatial object. + /// + public CartesianFrameAngleOrientationState AddAttachPoint( + SpatialObjectState spatialObject, + QualifiedName browseName, + NodeId baseFrameId, + ThreeDFrame frame, + EUInformation lengthUnit, + EUInformation angleUnit) + { + return AddFrame( + spatialObject, + SpatialFrameCollection.AttachPoints, + browseName, + baseFrameId, + frame, + lengthUnit, + angleUnit); + } + + /// + /// Adds a concrete Cartesian internal frame below a spatial object. + /// + public CartesianFrameAngleOrientationState AddInternalFrame( + SpatialObjectState spatialObject, + QualifiedName browseName, + NodeId baseFrameId, + ThreeDFrame frame, + EUInformation lengthUnit, + EUInformation angleUnit) + { + return AddFrame( + spatialObject, + SpatialFrameCollection.InternalFrames, + browseName, + baseFrameId, + frame, + lengthUnit, + angleUnit); + } + + /// + /// Adds a concrete Cartesian alternative frame below a spatial object. + /// + public CartesianFrameAngleOrientationState AddAlternativeFrame( + SpatialObjectState spatialObject, + QualifiedName browseName, + NodeId baseFrameId, + ThreeDFrame frame, + EUInformation lengthUnit, + EUInformation angleUnit) + { + return AddFrame( + spatialObject, + SpatialFrameCollection.AlternativeFrames, + browseName, + baseFrameId, + frame, + lengthUnit, + angleUnit); + } + + /// + /// Creates a GPOS Zone and links it from the standardized GlobalLocations entry point. + /// + /// + public ZoneState CreateZone( + QualifiedName browseName, + string zoneId, + ArrayOf groundControlPoints) + { + if (string.IsNullOrWhiteSpace(zoneId)) + { + throw new ArgumentException( + "A stable ZoneId is required.", + nameof(zoneId)); + } + ValidateGroundControlPoints(groundControlPoints); + + FolderState root = GetGlobalLocations(); + EnsureUniqueChild(root, browseName); + ZoneState zone = SystemContext.CreateInstanceOfZoneType(root, browseName); + zone.ReferenceTypeId = ReferenceTypeIds.Organizes; + root.AddChild(zone); + zone.ZoneId!.Value = zoneId; + + if (groundControlPoints.Count > 0) + { + zone.AddGroundControlPoints(SystemContext); + zone.GroundControlPoints!.Value = groundControlPoints; + } + + return zone; + } + + /// + /// Creates a proximity-based GPOS Zone around a geographic position. + /// + /// + public ZoneState CreateProximityZone( + QualifiedName browseName, + string zoneId, + S3DGeographicCoordinateDataType position, + double radius) + { + position.ThrowIfNull(nameof(position)); + if (!radius.IsFinite() || radius <= 0.0) + { + throw new ArgumentOutOfRangeException( + nameof(radius), + radius, + "The Zone radius must be finite and greater than zero."); + } + + ValidateGeographicPosition(position, coordinateReferenceSystem: 4326); + ZoneState zone = CreateZone( + browseName, + zoneId, + default); + zone.AddPosition(SystemContext); + zone.Position!.Value = position; + zone.AddRadius(SystemContext); + zone.Radius!.Value = radius; + return zone; + } + + /// + /// Attaches a GlobalPosition Variable to an existing tracked object. + /// + public GlobalPositionState AttachGlobalPosition( + NodeState owner, + QualifiedName browseName, + NodeId sourceId, + uint coordinateReferenceSystem) + { + owner.ThrowIfNull(nameof(owner)); + ValidateSourceNodeId(sourceId, nameof(sourceId)); + EnsureUniqueChild(owner, browseName); + + GlobalPositionState position = SystemContext + .CreateInstanceOfGlobalPositionType(owner, browseName); + position.ReferenceTypeId = ReferenceTypeIds.HasComponent; + owner.AddChild(position); + ConfigureGlobalPositionMetadata( + position, + sourceId, + coordinateReferenceSystem); + return position; + } + + /// + /// Attaches a GlobalLocation Variable to an existing tracked object. + /// + public GlobalLocationState AttachGlobalLocation( + NodeState owner, + QualifiedName browseName, + NodeId sourceId, + uint coordinateReferenceSystem, + bool includeOrientation = true) + { + owner.ThrowIfNull(nameof(owner)); + ValidateSourceNodeId(sourceId, nameof(sourceId)); + EnsureUniqueChild(owner, browseName); + + GlobalLocationState location = SystemContext + .CreateInstanceOfGlobalLocationType(owner, browseName); + location.ReferenceTypeId = ReferenceTypeIds.HasComponent; + owner.AddChild(location); + location.Base!.Value = NodeId.Null; + ConfigureGlobalPositionMetadata( + location.Position!, + sourceId, + coordinateReferenceSystem); + + if (includeOrientation) + { + location.AddOrientation(SystemContext); + } + + return location; + } + + /// + /// Applies a provider sample to a GlobalPosition Variable and its children. + /// + public void SetGlobalPositionValue( + GlobalPositionState state, + GeoLocationSample sample) + { + state.ThrowIfNull(nameof(state)); + + DateTimeUtc timestamp = sample.GetEffectiveSourceTimestamp(); + GlobalPositionDataType position = ToGlobalPosition( + sample, + RequireCoordinateReferenceSystem(state)); + ValidateGlobalPosition(state, position); + state.Value = position; + state.StatusCode = sample.StatusCode; + state.Timestamp = timestamp; + SetGlobalPositionComponents( + state, + position, + sample.StatusCode, + timestamp); + state.ClearChangeMasks(SystemContext, includeChildren: true); + } + + /// + /// Binds a GlobalPosition Variable to a provider source. + /// + public async ValueTask + BindGlobalPositionAsync( + GlobalPositionState state, + IGeoLocationProvider provider, + string sourceId, + CancellationToken cancellationToken = default) + { + state.ThrowIfNull(nameof(state)); + provider.ThrowIfNull(nameof(provider)); + ValidateRequestedSourceId(sourceId, nameof(sourceId)); + + try + { + GeoLocationSample initial = await provider.ReadAsync( + sourceId, + cancellationToken).ConfigureAwait(false); + ValidateProviderSourceMatch(initial.SourceId, sourceId); + SetGlobalPositionValue(state, initial); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + MarkGlobalPositionUnavailable(state); + state.ClearChangeMasks(SystemContext, includeChildren: true); + m_logger.GlobalProviderFailed(sourceId, ex); + throw; + } + + var cts = new CancellationTokenSource(); + Task completion = provider.SupportsPush + ? WatchGlobalPositionValueAsync( + state, + provider, + sourceId, + cts.Token) + : PollGlobalPositionValueAsync( + state, + provider, + sourceId, + cts.Token); + return new PositioningProviderSubscription(cts, completion); + } + + /// + /// Applies a provider sample to a GlobalLocation Variable and its children. + /// + public void SetGlobalLocationValue( + GlobalLocationState state, + GeoLocationSample sample) + { + state.ThrowIfNull(nameof(state)); + + DateTimeUtc timestamp = sample.GetEffectiveSourceTimestamp(); + GlobalLocationDataType value = ToGlobalLocation( + sample, + RequireCoordinateReferenceSystem(state.Position!)); + GlobalPositionDataType position = value.Position; + ValidateGlobalLocation(state, value); + state.Value = value; + state.StatusCode = sample.StatusCode; + state.Timestamp = timestamp; + + state.Position!.Value = position; + state.Position.StatusCode = sample.StatusCode; + state.Position.Timestamp = timestamp; + SetGlobalPositionComponents( + state.Position, + position, + sample.StatusCode, + timestamp); + + if ((value.EncodingMask & + (uint)GlobalLocationDataTypeFields.Orientation) != 0) + { + state.AddOrientation(SystemContext); + state.Orientation!.Value = value.Orientation; + state.Orientation.StatusCode = sample.StatusCode; + state.Orientation.Timestamp = timestamp; + SetVariable( + state.Orientation.A!, + value.Orientation.A, + sample.StatusCode, + timestamp); + SetVariable( + state.Orientation.B!, + value.Orientation.B, + sample.StatusCode, + timestamp); + SetVariable( + state.Orientation.C!, + value.Orientation.C, + sample.StatusCode, + timestamp); + } + else if (state.Orientation != null) + { + MarkNoData(state.Orientation, timestamp); + MarkNoData(state.Orientation.A, timestamp); + MarkNoData(state.Orientation.B, timestamp); + MarkNoData(state.Orientation.C, timestamp); + } + + state.ClearChangeMasks(SystemContext, includeChildren: true); + } + + /// + /// Builds the OPC 10000-211 location structure a provider sample + /// describes. + /// + /// The provider sample. + /// + /// The EPSG code the target Variable is configured for. + /// + /// The mapped location. + /// + /// The sample carries no position, or declares a different coordinate + /// reference system. + /// + private static GlobalLocationDataType ToGlobalLocation( + GeoLocationSample sample, + uint coordinateReferenceSystem) + { + var location = new GlobalLocationDataType + { + Position = ToGlobalPosition(sample, coordinateReferenceSystem) + }; + if (sample.Orientation is GeoOrientation orientation) + { + location.EncodingMask = + (uint)GlobalLocationDataTypeFields.Orientation; + location.Orientation = new ThreeDOrientation + { + A = orientation.A, + B = orientation.B, + C = orientation.C + }; + } + return location; + } + + /// + /// Builds the OPC 10000-211 position structure a provider sample + /// describes. + /// + /// The provider sample. + /// + /// The EPSG code the target Variable is configured for. + /// + /// The mapped position. + /// + /// The sample carries no position, or declares a different coordinate + /// reference system. + /// + private static GlobalPositionDataType ToGlobalPosition( + GeoLocationSample sample, + uint coordinateReferenceSystem) + { + if (sample.Position is not GeoPosition position) + { + throw new ServiceResultException( + StatusCodes.BadNoDataAvailable, + "The provider sample carries no geodetic position, which a " + + "global positioning Variable requires. A location that is " + + "only known as text cannot be published here."); + } + + // A provider that names its reference system must agree with the + // one the Variable is configured for; silently publishing WGS84 + // into a Variable declared as something else mis-georeferences it. + if (position.EpsgCode is uint epsg && + epsg != coordinateReferenceSystem) + { + throw new ServiceResultException( + StatusCodes.BadInvalidArgument, + $"The provider reports EPSG:{epsg} but the Variable is " + + $"configured for EPSG:{coordinateReferenceSystem}."); + } + + var mapped = new GlobalPositionDataType + { + Longitude = position.Longitude, + Latitude = position.Latitude + }; + uint encodingMask = 0; + if (position.Height is double height) + { + encodingMask |= (uint)S3DGeographicCoordinateDataTypeFields.Elevation; + mapped.Elevation = height; + } + if (position.Accuracy is double accuracy) + { + encodingMask |= (uint)GlobalPositionDataTypeFields.Accuracy; + mapped.Accuracy = accuracy; + } + if (position.Floor is float floor) + { + encodingMask |= (uint)GlobalPositionDataTypeFields.Floor; + mapped.Floor = floor; + } + mapped.EncodingMask = encodingMask; + return mapped; + } + + /// + /// Returns the EPSG code a GlobalPosition Variable is configured for. + /// + /// The Variable. + /// The configured EPSG code. + /// + /// The Variable has no coordinate reference system. + /// + private static uint RequireCoordinateReferenceSystem(GlobalPositionState state) + { + return state.CoordinateReferenceSystem?.Value ?? + throw new ServiceResultException( + StatusCodes.BadConfigurationError, + "CoordinateReferenceSystem is not configured."); + } + + /// + /// Binds a GlobalLocation Variable to a provider source. + /// + public async ValueTask + BindGlobalLocationAsync( + GlobalLocationState state, + IGeoLocationProvider provider, + string sourceId, + Func? + onSampleApplied = null, + CancellationToken cancellationToken = default) + { + state.ThrowIfNull(nameof(state)); + provider.ThrowIfNull(nameof(provider)); + ValidateRequestedSourceId(sourceId, nameof(sourceId)); + + try + { + GeoLocationSample initial = await provider.ReadAsync( + sourceId, + cancellationToken).ConfigureAwait(false); + ApplyGlobalSample(state, initial, sourceId); + if (onSampleApplied != null) + { + await onSampleApplied(initial, cancellationToken) + .ConfigureAwait(false); + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + MarkGlobalLocationUnavailable(state); + m_logger.GlobalProviderFailed(sourceId, ex); + throw; + } + + var cts = new CancellationTokenSource(); + Task completion = provider.SupportsPush + ? WatchGlobalPositionAsync( + state, + provider, + sourceId, + onSampleApplied, + cts.Token) + : PollGlobalLocationAsync( + state, + provider, + sourceId, + onSampleApplied, + cts.Token); + return new PositioningProviderSubscription(cts, completion); + } + + /// + /// Binds an RSL Cartesian frame to a provider source. + /// + public async ValueTask + BindRelativeSpatialLocationAsync( + CartesianFrameAngleOrientationState state, + IRelativeSpatialLocationProvider provider, + string sourceId, + CancellationToken cancellationToken = default) + { + state.ThrowIfNull(nameof(state)); + provider.ThrowIfNull(nameof(provider)); + ValidateRequestedSourceId(sourceId, nameof(sourceId)); + + try + { + RelativeSpatialLocationSample initial = await provider.ReadAsync( + sourceId, + cancellationToken).ConfigureAwait(false); + ValidateProviderSourceMatch(initial.SourceId, sourceId); + SetFrameValue( + state, + initial.Frame, + initial.StatusCode, + initial.SourceTimestamp); + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + MarkFrameUnavailable(state); + m_logger.RelativeProviderFailed(sourceId, ex); + throw; + } + + var cts = new CancellationTokenSource(); + Task completion = WatchRelativeSpatialLocationAsync( + state, + provider, + sourceId, + cts.Token); + return new PositioningProviderSubscription(cts, completion); + } + + /// + /// Creates a concrete Cartesian frame and synchronizes its aggregate and child values. + /// + public CartesianFrameAngleOrientationState CreateCartesianFrame( + NodeState parent, + QualifiedName browseName, + NodeId baseFrameId, + ThreeDFrame frame, + EUInformation lengthUnit, + EUInformation angleUnit) + { + parent.ThrowIfNull(nameof(parent)); + frame.ThrowIfNull(nameof(frame)); + lengthUnit.ThrowIfNull(nameof(lengthUnit)); + angleUnit.ThrowIfNull(nameof(angleUnit)); + ValidateFrame(frame, nameof(frame)); + + CartesianFrameAngleOrientationState state = SystemContext + .CreateInstanceOfCartesianFrameAngleOrientationType( + parent, + browseName); + state.ReferenceTypeId = ReferenceTypeIds.HasComponent; + state.Base!.Value = baseFrameId; + SetFrameValue(state, frame, StatusCodes.Good, DateTimeUtc.Now); + state.Position!.LengthUnit!.Value = lengthUnit; + state.Orientation!.AngleUnit!.Value = angleUnit; + return state; + } + + /// + /// Updates a Cartesian frame and all mandatory child Variables coherently. + /// + public void SetFrameValue( + CartesianFrameAngleOrientationState state, + ThreeDFrame frame, + StatusCode statusCode, + DateTimeUtc sourceTimestamp) + { + state.ThrowIfNull(nameof(state)); + frame.ThrowIfNull(nameof(frame)); + + state.Value = frame; + state.StatusCode = statusCode; + state.Timestamp = sourceTimestamp; + state.Position!.Value = frame.CartesianCoordinates; + state.Position.StatusCode = statusCode; + state.Position.Timestamp = sourceTimestamp; + SetVariable( + state.Position.X!, + frame.CartesianCoordinates.X, + statusCode, + sourceTimestamp); + SetVariable( + state.Position.Y!, + frame.CartesianCoordinates.Y, + statusCode, + sourceTimestamp); + SetVariable( + state.Position.Z!, + frame.CartesianCoordinates.Z, + statusCode, + sourceTimestamp); + state.Orientation!.Value = frame.Orientation; + state.Orientation.StatusCode = statusCode; + state.Orientation.Timestamp = sourceTimestamp; + SetVariable( + state.Orientation.A!, + frame.Orientation.A, + statusCode, + sourceTimestamp); + SetVariable( + state.Orientation.B!, + frame.Orientation.B, + statusCode, + sourceTimestamp); + SetVariable( + state.Orientation.C!, + frame.Orientation.C, + statusCode, + sourceTimestamp); + state.ClearChangeMasks(SystemContext, includeChildren: true); + } + + private ushort RslNamespaceIndex => + (ushort)NodeManager.Server.NamespaceUris.GetIndex( + Rsl.Namespaces.RSL); + + private FolderState GetRelativeSpatialLocations() + { + var nodeId = NodeId.Create( + Rsl.Objects.RelativeSpatialLocations, + Rsl.Namespaces.RSL, + NodeManager.Server.NamespaceUris); + return NodeManager.FindPredefinedNode(nodeId) ?? + throw new ServiceResultException( + StatusCodes.BadNodeIdUnknown, + "The RSL RelativeSpatialLocations entry point is not loaded."); + } + + private FolderState GetGlobalLocations() + { + var nodeId = NodeId.Create( + Gpos.Objects.GlobalLocations, + Gpos.Namespaces.GPOS, + NodeManager.Server.NamespaceUris); + return NodeManager.FindPredefinedNode(nodeId) ?? + throw new ServiceResultException( + StatusCodes.BadNodeIdUnknown, + "The GPOS GlobalLocations entry point is not loaded."); + } + + private static void IncrementNodeVersion(SpatialObjectsListState list) + { + _ = int.TryParse( + list.NodeVersion?.Value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int value); + list.NodeVersion!.Value = (value + 1).ToString( + CultureInfo.InvariantCulture); + } + + private void ApplyGlobalSample( + GlobalLocationState state, + GeoLocationSample sample, + string sourceId) + { + ValidateProviderSourceMatch(sample.SourceId, sourceId); + SetGlobalLocationValue(state, sample); + } + + private async Task WatchGlobalPositionAsync( + GlobalLocationState state, + IGeoLocationProvider provider, + string sourceId, + Func? + onSampleApplied, + CancellationToken cancellationToken) + { + try + { + await foreach (GeoLocationSample sample in provider + .WatchAsync(sourceId, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + ApplyGlobalSample(state, sample, sourceId); + if (onSampleApplied != null) + { + await onSampleApplied(sample, cancellationToken) + .ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + MarkGlobalLocationUnavailable(state); + m_logger.GlobalProviderFailed(sourceId, ex); + throw; + } + } + + private async Task WatchGlobalPositionValueAsync( + GlobalPositionState state, + IGeoLocationProvider provider, + string sourceId, + CancellationToken cancellationToken) + { + try + { + await foreach (GeoLocationSample sample in provider + .WatchAsync(sourceId, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + ValidateProviderSourceMatch(sample.SourceId, sourceId); + SetGlobalPositionValue(state, sample); + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + MarkGlobalPositionUnavailable(state); + state.ClearChangeMasks(SystemContext, includeChildren: true); + m_logger.GlobalProviderFailed(sourceId, ex); + throw; + } + } + + private async Task PollGlobalPositionValueAsync( + GlobalPositionState state, + IGeoLocationProvider provider, + string sourceId, + CancellationToken cancellationToken) + { + TimeSpan interval = GetPollInterval(state); + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(interval, cancellationToken) + .ConfigureAwait(false); + GeoLocationSample sample = await provider + .ReadAsync(sourceId, cancellationToken) + .ConfigureAwait(false); + ValidateProviderSourceMatch(sample.SourceId, sourceId); + SetGlobalPositionValue(state, sample); + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + MarkGlobalPositionUnavailable(state); + state.ClearChangeMasks(SystemContext, includeChildren: true); + m_logger.GlobalProviderFailed(sourceId, ex); + throw; + } + } + + private async Task PollGlobalLocationAsync( + GlobalLocationState state, + IGeoLocationProvider provider, + string sourceId, + Func? + onSampleApplied, + CancellationToken cancellationToken) + { + TimeSpan interval = GetPollInterval(state); + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(interval, cancellationToken) + .ConfigureAwait(false); + GeoLocationSample sample = await provider + .ReadAsync(sourceId, cancellationToken) + .ConfigureAwait(false); + ApplyGlobalSample(state, sample, sourceId); + if (onSampleApplied != null) + { + await onSampleApplied(sample, cancellationToken) + .ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + MarkGlobalLocationUnavailable(state); + m_logger.GlobalProviderFailed(sourceId, ex); + throw; + } + } + + /// + /// Returns how often a provider that cannot push is polled: the + /// Variable's own sampling interval when it declares one, otherwise a + /// one-second default. + /// + private static TimeSpan GetPollInterval(BaseVariableState state) + { + double milliseconds = state.MinimumSamplingInterval; + return milliseconds > 0 + ? TimeSpan.FromMilliseconds(milliseconds) + : TimeSpan.FromMilliseconds(kDefaultPollIntervalMilliseconds); + } + + private async Task WatchRelativeSpatialLocationAsync( + CartesianFrameAngleOrientationState state, + IRelativeSpatialLocationProvider provider, + string sourceId, + CancellationToken cancellationToken) + { + try + { + await foreach (RelativeSpatialLocationSample sample in provider + .WatchAsync(sourceId, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + ValidateProviderSourceMatch(sample.SourceId, sourceId); + SetFrameValue( + state, + sample.Frame, + sample.StatusCode, + sample.SourceTimestamp); + } + } + catch (OperationCanceledException) + when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + MarkFrameUnavailable(state); + m_logger.RelativeProviderFailed(sourceId, ex); + throw; + } + } + + private CartesianFrameAngleOrientationState AddFrame( + SpatialObjectState spatialObject, + SpatialFrameCollection collection, + QualifiedName browseName, + NodeId baseFrameId, + ThreeDFrame frame, + EUInformation lengthUnit, + EUInformation angleUnit) + { + spatialObject.ThrowIfNull(nameof(spatialObject)); + ValidateSourceNodeId(baseFrameId, nameof(baseFrameId)); + + FolderState folder = collection switch + { + SpatialFrameCollection.AlternativeFrames => + GetAlternativeFrames(spatialObject), + SpatialFrameCollection.AttachPoints => + GetAttachPoints(spatialObject), + SpatialFrameCollection.InternalFrames => + GetInternalFrames(spatialObject), + _ => throw new ArgumentOutOfRangeException(nameof(collection)) + }; + EnsureUniqueChild(folder, browseName); + + CartesianFrameAngleOrientationState child = CreateCartesianFrame( + folder, + browseName, + baseFrameId, + frame, + lengthUnit, + angleUnit); + child.ReferenceTypeId = ReferenceTypeIds.HasComponent; + folder.AddChild(child); + TrackModelChange(child, spatialObject); + return child; + } + + private void SynchronizeFrameSubtree(NodeState node) + { + if (node is CartesianFrameAngleOrientationState frameState && + frameState.Value != null) + { + SetFrameValue( + frameState, + frameState.Value, + frameState.StatusCode, + frameState.Timestamp); + } + + var children = new List(); + node.GetChildren(SystemContext, children); + for (int i = 0; i < children.Count; i++) + { + SynchronizeFrameSubtree(children[i]); + } + } + + private FolderState GetAlternativeFrames(SpatialObjectState spatialObject) + { + spatialObject.AddAlternativeFrames(SystemContext); + return spatialObject.AlternativeFrames!; + } + + private FolderState GetAttachPoints(SpatialObjectState spatialObject) + { + spatialObject.AddAttachPoints(SystemContext); + return spatialObject.AttachPoints!; + } + + private FolderState GetInternalFrames(SpatialObjectState spatialObject) + { + spatialObject.AddInternalFrames(SystemContext); + return spatialObject.InternalFrames!; + } + + private void TrackModelChange( + NodeState node, + SpatialObjectState spatialObject) + { + if (NodeManager.Find(spatialObject.NodeId) != null && + m_spatialObjectLists.TryGetValue( + spatialObject, + out SpatialObjectsListState? list)) + { + m_pendingModelChanges[node] = list; + } + } + + private static void ConfigureGlobalPositionMetadata( + GlobalPositionState position, + NodeId sourceId, + uint coordinateReferenceSystem) + { + position.SourceId!.Value = sourceId; + position.CoordinateReferenceSystem!.Value = + coordinateReferenceSystem; + } + + private void SetGlobalPositionComponents( + GlobalPositionState state, + GlobalPositionDataType position, + StatusCode statusCode, + DateTimeUtc sourceTimestamp) + { + SetVariable( + state.Longitude!, + position.Longitude, + statusCode, + sourceTimestamp); + SetVariable( + state.Latitude!, + position.Latitude, + statusCode, + sourceTimestamp); + + if ((position.EncodingMask & + (uint)S3DGeographicCoordinateDataTypeFields.Elevation) != 0) + { + state.AddElevation(SystemContext); + SetVariable( + state.Elevation!, + position.Elevation, + statusCode, + sourceTimestamp); + } + else + { + MarkNoData(state.Elevation, sourceTimestamp); + } + + if ((position.EncodingMask & + (uint)GlobalPositionDataTypeFields.Accuracy) != 0) + { + state.AddAccuracy(SystemContext); + SetVariable( + state.Accuracy!, + position.Accuracy, + statusCode, + sourceTimestamp); + } + else + { + MarkNoData(state.Accuracy, sourceTimestamp); + } + + if ((position.EncodingMask & + (uint)GlobalPositionDataTypeFields.Floor) != 0) + { + state.AddFloor(SystemContext); + SetVariable( + state.Floor!, + position.Floor, + statusCode, + sourceTimestamp); + } + else + { + MarkNoData(state.Floor, sourceTimestamp); + } + } + + private void SetVariable( + BaseDataVariableState state, + T value, + StatusCode statusCode, + DateTimeUtc sourceTimestamp) + { + state.Value = value; + state.StatusCode = statusCode; + state.Timestamp = sourceTimestamp; + + if (!state.NodeId.IsNull && + NodeManager.Find(state.NodeId) is BaseDataVariableState + registeredState && + !ReferenceEquals(state, registeredState)) + { + registeredState.Value = value; + registeredState.StatusCode = statusCode; + registeredState.Timestamp = sourceTimestamp; + registeredState.ClearChangeMasks( + SystemContext, + includeChildren: false); + } + } + + private void MarkNoData( + BaseVariableState? state, + DateTimeUtc sourceTimestamp) + { + SetVariableStatus( + state, + StatusCodes.BadNoData, + sourceTimestamp); + } + + private void MarkGlobalLocationUnavailable(GlobalLocationState state) + { + MarkUnavailable(state); + MarkGlobalPositionUnavailable(state.Position!); + MarkUnavailable(state.Orientation); + MarkUnavailable(state.Orientation?.A); + MarkUnavailable(state.Orientation?.B); + MarkUnavailable(state.Orientation?.C); + state.ClearChangeMasks(SystemContext, includeChildren: true); + } + + private void MarkGlobalPositionUnavailable(GlobalPositionState state) + { + MarkUnavailable(state); + MarkUnavailable(state.Longitude); + MarkUnavailable(state.Latitude); + MarkUnavailable(state.Elevation); + MarkUnavailable(state.Accuracy); + MarkUnavailable(state.Floor); + } + + private void MarkFrameUnavailable( + CartesianFrameAngleOrientationState state) + { + MarkUnavailable(state); + MarkUnavailable(state.Position); + MarkUnavailable(state.Position?.X); + MarkUnavailable(state.Position?.Y); + MarkUnavailable(state.Position?.Z); + MarkUnavailable(state.Orientation); + MarkUnavailable(state.Orientation?.A); + MarkUnavailable(state.Orientation?.B); + MarkUnavailable(state.Orientation?.C); + state.ClearChangeMasks(SystemContext, includeChildren: true); + } + + private void MarkUnavailable(BaseVariableState? state) + { + if (state != null) + { + SetVariableStatus( + state, + StatusCodes.BadCommunicationError, + state.Timestamp); + } + } + + private void SetVariableStatus( + BaseVariableState? state, + StatusCode statusCode, + DateTimeUtc sourceTimestamp) + { + if (state == null) + { + return; + } + + state.StatusCode = statusCode; + state.Timestamp = sourceTimestamp; + + if (!state.NodeId.IsNull && + NodeManager.Find(state.NodeId) is BaseVariableState + registeredState && + !ReferenceEquals(state, registeredState)) + { + registeredState.StatusCode = statusCode; + registeredState.Timestamp = sourceTimestamp; + registeredState.ClearChangeMasks( + SystemContext, + includeChildren: false); + } + } + + private static void ValidateGlobalLocation( + GlobalLocationState state, + GlobalLocationDataType location) + { + location.Position.ThrowIfNull(nameof(location.Position)); + ValidateGlobalPosition(state.Position!, location.Position); + if ((location.EncodingMask & + (uint)GlobalLocationDataTypeFields.Orientation) != 0) + { + ThreeDOrientation orientation = location.Orientation; + if (!orientation.A.IsFinite() || + !orientation.B.IsFinite() || + !orientation.C.IsFinite()) + { + throw new ServiceResultException( + StatusCodes.BadOutOfRange, + "The global orientation contains a non-finite angle."); + } + } + } + + private static void ValidateGlobalPosition( + GlobalPositionState state, + GlobalPositionDataType position) + { + position.ThrowIfNull(nameof(position)); + uint coordinateReferenceSystem = + state.CoordinateReferenceSystem?.Value ?? + throw new ServiceResultException( + StatusCodes.BadConfigurationError, + "CoordinateReferenceSystem is not configured."); + ValidateGeographicPosition(position, coordinateReferenceSystem); + + if ((position.EncodingMask & + (uint)GlobalPositionDataTypeFields.Accuracy) != 0 && + (!position.Accuracy.IsFinite() || + position.Accuracy < 0.0)) + { + throw new ServiceResultException( + StatusCodes.BadOutOfRange, + "Accuracy must be finite and non-negative."); + } + + if ((position.EncodingMask & + (uint)GlobalPositionDataTypeFields.Floor) != 0 && + (float.IsNaN(position.Floor) || + float.IsInfinity(position.Floor))) + { + throw new ServiceResultException( + StatusCodes.BadOutOfRange, + "Floor must be finite."); + } + } + + private static void ValidateGeographicPosition( + S3DGeographicCoordinateDataType position, + uint coordinateReferenceSystem) + { + if (!position.Longitude.IsFinite() || + !position.Latitude.IsFinite()) + { + throw new ServiceResultException( + StatusCodes.BadOutOfRange, + "The global position contains a non-finite coordinate."); + } + + if (coordinateReferenceSystem == 4326 && + (position.Longitude < -180.0 || + position.Longitude > 180.0 || + position.Latitude < -90.0 || + position.Latitude > 90.0)) + { + throw new ServiceResultException( + StatusCodes.BadOutOfRange, + "EPSG:4326 longitude or latitude is outside its valid range."); + } + + if ((position.EncodingMask & + (uint)S3DGeographicCoordinateDataTypeFields.Elevation) != 0 && + !position.Elevation.IsFinite()) + { + throw new ServiceResultException( + StatusCodes.BadOutOfRange, + "Elevation must be finite when present."); + } + } + + private static void ValidateGroundControlPoints( + ArrayOf controlPoints) + { + if (controlPoints.IsNull) + { + return; + } + + for (int i = 0; i < controlPoints.Count; i++) + { + GroundControlPointDataType controlPoint = controlPoints[i] ?? + throw new ArgumentException( + $"Ground control point at index {i} is null.", + nameof(controlPoints)); + ThreeDCartesianCoordinates local = + controlPoint.LocalPosition ?? + throw new ArgumentException( + $"Ground control point at index {i} has no local position.", + nameof(controlPoints)); + if (!local.X.IsFinite() || + !local.Y.IsFinite() || + !local.Z.IsFinite()) + { + throw new ArgumentException( + $"Ground control point at index {i} has a non-finite local position.", + nameof(controlPoints)); + } + ValidateGeographicPosition( + controlPoint.GlobalPosition ?? + throw new ArgumentException( + $"Ground control point at index {i} has no global position.", + nameof(controlPoints)), + coordinateReferenceSystem: 4326); + } + } + + private static void ValidateFrame( + ThreeDFrame frame, + string parameterName) + { + ThreeDCartesianCoordinates position = + frame.CartesianCoordinates ?? + throw new ArgumentException( + "The frame has no CartesianCoordinates.", + parameterName); + ThreeDOrientation orientation = + frame.Orientation ?? + throw new ArgumentException( + "The frame has no Orientation.", + parameterName); + if (!position.X.IsFinite() || + !position.Y.IsFinite() || + !position.Z.IsFinite() || + !orientation.A.IsFinite() || + !orientation.B.IsFinite() || + !orientation.C.IsFinite()) + { + throw new ArgumentException( + "The frame contains a non-finite coordinate or angle.", + parameterName); + } + } + + private static void ValidateSourceNodeId( + NodeId sourceId, + string parameterName) + { + if (sourceId.IsNull) + { + throw new ArgumentException( + "A non-null source or Base NodeId is required.", + parameterName); + } + } + + private static void ValidateRequestedSourceId( + string sourceId, + string parameterName) + { + if (string.IsNullOrWhiteSpace(sourceId)) + { + throw new ArgumentException( + "A stable provider source identifier is required.", + parameterName); + } + } + + private static void ValidateProviderSourceMatch( + string? actualSourceId, + string expectedSourceId) + { + // Echoing the source is optional; a provider that does identifies + // its own bugs, one that does not is simply trusted. + if (actualSourceId == null) + { + return; + } + ValidateRequestedSourceId(actualSourceId, nameof(actualSourceId)); + if (!string.Equals( + actualSourceId, + expectedSourceId, + StringComparison.Ordinal)) + { + throw new ServiceResultException( + StatusCodes.BadInvalidArgument, + $"Provider source '{actualSourceId}' does not match requested source " + + $"'{expectedSourceId}'."); + } + } + + private void EnsureUniqueChild( + NodeState parent, + QualifiedName browseName) + { + var children = new List(); + parent.GetChildren(SystemContext, children); + for (int i = 0; i < children.Count; i++) + { + if (children[i].BrowseName == browseName) + { + throw new ServiceResultException( + StatusCodes.BadBrowseNameDuplicated, + $"A child named '{browseName}' already exists below " + + $"'{parent.NodeId}'."); + } + } + } + + private void ReportModelChange( + BaseInstanceState affected, + ModelChangeVerbs verb) + { + var modelChange = new GeneralModelChangeEventState(null); + var message = new TranslationInfo( + "PositioningModelChange", + "en-US", + "Positioning address space changed."); + modelChange.Initialize( + SystemContext, + null, + EventSeverity.Low, + new LocalizedText(message)); + modelChange.SetChildValue( + SystemContext, + BrowseNames.SourceNode, + ObjectIds.Server, + false); + modelChange.SetChildValue( + SystemContext, + BrowseNames.SourceName, + "Server", + false); + modelChange.CreateOrReplaceChanges(SystemContext, null!); + modelChange.Changes!.Value = + [ + new ModelChangeStructureDataType + { + Affected = affected.NodeId, + AffectedType = affected.TypeDefinitionId, + Verb = (byte)verb + } + ]; + NodeManager.Server.ReportEvent(modelChange); + } + + private enum SpatialFrameCollection + { + AlternativeFrames, + AttachPoints, + InternalFrames + } + } + + internal static partial class PositioningAddressSpaceBuilderLog + { + [LoggerMessage( + EventId = PositioningServerEventIds.GlobalProviderFailed, + Level = LogLevel.Error, + Message = "Global positioning provider source {SourceId} stopped.")] + public static partial void GlobalProviderFailed( + this ILogger logger, + string sourceId, + Exception exception); + + [LoggerMessage( + EventId = PositioningServerEventIds.RelativeProviderFailed, + Level = LogLevel.Error, + Message = "Relative spatial location provider source {SourceId} stopped.")] + public static partial void RelativeProviderFailed( + this ILogger logger, + string sourceId, + Exception exception); + } +} diff --git a/src/Opc.Ua.Positioning.Server/PositioningNodeManager.cs b/src/Opc.Ua.Positioning.Server/PositioningNodeManager.cs new file mode 100644 index 0000000000..19643255db --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/PositioningNodeManager.cs @@ -0,0 +1,139 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua.Gpos; +using Opc.Ua.Positioning.Server.Hosting; +using Opc.Ua.Rsl; +using Opc.Ua.Server; +using Opc.Ua.Server.Fluent; + +namespace Opc.Ua.Positioning.Server +{ + /// + /// Standalone node manager for the RSL and GPOS models. + /// + public sealed class PositioningNodeManager : FluentNodeManagerBase, INodeIdFactory + { + private readonly IPositioningPostSetupRunner? m_runner; + + /// + /// Creates a standalone Positioning node manager. + /// + public PositioningNodeManager( + IServerInternal server, + ApplicationConfiguration configuration) + : this(server, configuration, runner: null) + { + } + + /// + /// Creates a Positioning node manager with hosting configurator support. + /// + public PositioningNodeManager( + IServerInternal server, + ApplicationConfiguration configuration, + IPositioningPostSetupRunner? runner) + : base( + server, + configuration, + server.Telemetry.CreateLogger(), + Rsl.Namespaces.RSL, + Gpos.Namespaces.GPOS) + { + m_runner = runner; + SystemContext.NodeIdFactory = this; + } + + /// + public override NodeId New(ISystemContext context, NodeState node) + { + if (node is BaseInstanceState instance && instance.Parent != null) + { + return new NodeId( + $"{instance.Parent.NodeId.IdentifierAsString}_{instance.SymbolicName}", + instance.Parent.NodeId.NamespaceIndex); + } + + return node.NodeId; + } + + /// + /// Creates an address-space builder bound to this manager. + /// + public PositioningAddressSpaceBuilder CreatePositioningBuilder() + { + return new PositioningAddressSpaceBuilder(this); + } + + /// + protected override ValueTask LoadPredefinedNodesAsync( + ISystemContext context, + CancellationToken cancellationToken = default) + { + var nodes = new NodeStateCollection(); + nodes.AddOpcUaRsl(context); + nodes.AddOpcUaGpos(context); + return new ValueTask(nodes); + } + + /// + public override async ValueTask CreateAddressSpaceAsync( + IDictionary> externalReferences, + CancellationToken cancellationToken = default) + { + await base.CreateAddressSpaceAsync( + externalReferences, + cancellationToken).ConfigureAwait(false); + await PositioningNamespaceMetadata.EnsureAsync( + this, + cancellationToken).ConfigureAwait(false); + + if (m_runner != null) + { + await m_runner.RunAsync(this, cancellationToken) + .ConfigureAwait(false); + } + + m_logger.NodeManagerReady(); + } + } + + internal static partial class PositioningNodeManagerLog + { + [LoggerMessage( + EventId = PositioningServerEventIds.NodeManagerReady, + Level = LogLevel.Information, + Message = "Positioning node manager loaded RSL and GPOS.")] + public static partial void NodeManagerReady(this ILogger logger); + } +} diff --git a/src/Opc.Ua.Positioning.Server/PositioningNodeManagerFactory.cs b/src/Opc.Ua.Positioning.Server/PositioningNodeManagerFactory.cs new file mode 100644 index 0000000000..d5c77870aa --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/PositioningNodeManagerFactory.cs @@ -0,0 +1,83 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Positioning.Server.Hosting; +using Opc.Ua.Server; + +namespace Opc.Ua.Positioning.Server +{ + /// + /// Creates standalone instances. + /// + public sealed class PositioningNodeManagerFactory : IAsyncNodeManagerFactory + { + private readonly IPositioningPostSetupRunner? m_runner; + + /// + /// Creates a factory without dependency-injection configurators. + /// + public PositioningNodeManagerFactory() + : this(null) + { + } + + /// + /// Creates a factory with Positioning post-setup support. + /// + public PositioningNodeManagerFactory(IPositioningPostSetupRunner? runner) + { + m_runner = runner; + } + + /// + public ArrayOf NamespacesUris => + [ + Rsl.Namespaces.RSL, + Gpos.Namespaces.GPOS + ]; + + /// + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership is transferred to the server.")] + public ValueTask CreateAsync( + IServerInternal server, + ApplicationConfiguration configuration, + CancellationToken cancellationToken = default) + { + IAsyncNodeManager manager = + new PositioningNodeManager(server, configuration, m_runner); + return new ValueTask(manager); + } + } +} diff --git a/src/Opc.Ua.Positioning.Server/PositioningProviderSubscription.cs b/src/Opc.Ua.Positioning.Server/PositioningProviderSubscription.cs new file mode 100644 index 0000000000..2b65b6903f --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/PositioningProviderSubscription.cs @@ -0,0 +1,82 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Positioning.Server +{ + /// + /// Owns a provider watch loop created by . + /// + public sealed class PositioningProviderSubscription : + IDisposable, + IAsyncDisposable + { + private readonly CancellationTokenSource m_cts; + private int m_disposed; + + internal PositioningProviderSubscription( + CancellationTokenSource cts, + Task completion) + { + m_cts = cts; + Completion = completion; + } + + /// + /// Provider loop completion. + /// + public Task Completion { get; } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref m_disposed, 1) == 0) + { + m_cts.Cancel(); + m_cts.Dispose(); + } + } + + /// + public async ValueTask DisposeAsync() + { + Dispose(); + try + { + await Completion.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + } + } +} diff --git a/src/Opc.Ua.Positioning.Server/Properties/AssemblyInfo.cs b/src/Opc.Ua.Positioning.Server/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.Positioning.Server/Providers/IRelativeSpatialLocationProvider.cs b/src/Opc.Ua.Positioning.Server/Providers/IRelativeSpatialLocationProvider.cs new file mode 100644 index 0000000000..7f836877b9 --- /dev/null +++ b/src/Opc.Ua.Positioning.Server/Providers/IRelativeSpatialLocationProvider.cs @@ -0,0 +1,103 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Positioning.Server +{ + /// + /// Supplies relative spatial frames for one or more stable source identifiers. + /// + public interface IRelativeSpatialLocationProvider + { + /// + /// Reads the current frame for . + /// + ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken); + + /// + /// Streams subsequent frames for . + /// + IAsyncEnumerable WatchAsync( + string sourceId, + CancellationToken cancellationToken); + } + + /// + /// A provider sample containing an RSL frame and OPC UA quality metadata. + /// + public sealed class RelativeSpatialLocationSample + { + /// + /// Creates a relative spatial location sample. + /// + public RelativeSpatialLocationSample( + string sourceId, + ThreeDFrame frame, + StatusCode statusCode, + DateTimeUtc sourceTimestamp) + { + if (string.IsNullOrWhiteSpace(sourceId)) + { + throw new System.ArgumentException( + "A stable source identifier is required.", + nameof(sourceId)); + } + + SourceId = sourceId; + Frame = frame ?? throw new System.ArgumentNullException(nameof(frame)); + StatusCode = statusCode; + SourceTimestamp = sourceTimestamp; + } + + /// + /// Stable provider-local source identifier. + /// + public string SourceId { get; } + + /// + /// Relative frame value. + /// + public ThreeDFrame Frame { get; } + + /// + /// OPC UA status for the sample. + /// + public StatusCode StatusCode { get; } + + /// + /// Source timestamp for the sample. + /// + public DateTimeUtc SourceTimestamp { get; } + } +} diff --git a/src/Opc.Ua.Positioning/Model/Opc.Ua.GPOS.NodeSet2.csv b/src/Opc.Ua.Positioning/Model/Opc.Ua.GPOS.NodeSet2.csv new file mode 100644 index 0000000000..02dbc8b33e --- /dev/null +++ b/src/Opc.Ua.Positioning/Model/Opc.Ua.GPOS.NodeSet2.csv @@ -0,0 +1,69 @@ +ZoneType,1005,ObjectType +GlobalLocationType,2003,VariableType +GlobalPositionType,2006,VariableType +S3DGeographicCoordinateDataType,3003,DataType +GlobalLocationDataType,3004,DataType +GroundControlPointDataType,3005,DataType +GlobalPositionDataType,3006,DataType +GlobalPositionDataType_Encoding_DefaultBinary,5001,Object +GlobalPositionDataType_Encoding_DefaultXml,5002,Object +GlobalLocationDataType_Encoding_DefaultBinary,5004,Object +GlobalLocationDataType_Encoding_DefaultXml,5005,Object +Server_Namespaces_http___opcfoundation_org_UA_GPOS_,5007,Object +GroundControlPointDataType_Encoding_DefaultBinary,5008,Object +GroundControlPointDataType_Encoding_DefaultXml,5009,Object +Locations_GlobalLocations,5013,Object +S3DGeographicCoordinateDataType_Encoding_DefaultBinary,5014,Object +S3DGeographicCoordinateDataType_Encoding_DefaultXml,5015,Object +TypeDictionary_BinarySchema,6001,Variable +TypeDictionary_BinarySchema_NamespaceUri,6002,Variable +TypeDictionary_BinarySchema_Deprecated,6003,Variable +TypeDictionary_XmlSchema,6004,Variable +TypeDictionary_XmlSchema_NamespaceUri,6005,Variable +TypeDictionary_XmlSchema_Deprecated,6006,Variable +TypeDictionary_BinarySchema_S3DGeographicCoordinateDataType,6007,Variable +GlobalLocationType_Orientation,6008,Variable +GlobalLocationType_Position,6009,Variable +ZoneType_Building,6010,Variable +ZoneType_Floor,6011,Variable +GlobalPositionType_Floor,6012,Variable +ZoneType_IncompleteConfiguration,6013,Variable +GlobalLocationType_Orientation_A,6014,Variable +GlobalLocationType_Orientation_B,6015,Variable +GlobalLocationType_Orientation_C,6016,Variable +TypeDictionary_XmlSchema_S3DGeographicCoordinateDataType,6017,Variable +TypeDictionary_BinarySchema_GlobalPositionDataType,6018,Variable +Server_Namespaces_http___opcfoundation_org_UA_GPOS__IsNamespaceSubset,6019,Variable +Server_Namespaces_http___opcfoundation_org_UA_GPOS__NamespacePublicationDate,6020,Variable +Server_Namespaces_http___opcfoundation_org_UA_GPOS__NamespaceUri,6021,Variable +Server_Namespaces_http___opcfoundation_org_UA_GPOS__NamespaceVersion,6022,Variable +Server_Namespaces_http___opcfoundation_org_UA_GPOS__StaticNodeIdTypes,6023,Variable +Server_Namespaces_http___opcfoundation_org_UA_GPOS__StaticNumericNodeIdRange,6024,Variable +Server_Namespaces_http___opcfoundation_org_UA_GPOS__StaticStringNodeIdPattern,6025,Variable +ZoneType_Site,6026,Variable +GlobalPositionType_Longitude,6027,Variable +GlobalPositionType_Accuracy,6028,Variable +GlobalPositionType_Latitude,6029,Variable +GlobalPositionType_Elevation,6030,Variable +GlobalLocationType_Position_Longitude,6031,Variable +GlobalLocationType_Position_Latitude,6032,Variable +GlobalPositionType_CoordinateReferenceSystem,6033,Variable +GlobalPositionType_CoordinateReferenceSystem_EnumValues,6034,Variable +GlobalPositionType_CoordinateReferenceSystem_ValueAsText,6035,Variable +GlobalLocationType_Position_CoordinateReferenceSystem,6036,Variable +GlobalLocationType_Position_CoordinateReferenceSystem_EnumValues,6037,Variable +GlobalPositionType_SourceId,6038,Variable +GlobalLocationType_Position_SourceId,6039,Variable +TypeDictionary_XmlSchema_GlobalPositionDataType,6040,Variable +TypeDictionary_BinarySchema_GlobalLocationDataType,6041,Variable +TypeDictionary_XmlSchema_GlobalLocationDataType,6042,Variable +ZoneType_ZoneId,6043,Variable +TypeDictionary_BinarySchema_GroundControlPointDataType,6044,Variable +ZoneType_Position,6045,Variable +ZoneType_Radius,6046,Variable +ZoneType_GroundControlPoints,6047,Variable +GlobalLocationType_Position_CoordinateReferenceSystem_ValueAsText,6048,Variable +GlobalPositionType_ElevationReference,6049,Variable +GlobalPositionType_ElevationReference_EnumValues,6050,Variable +GlobalPositionType_ElevationReference_ValueAsText,6051,Variable +TypeDictionary_XmlSchema_GroundControlPointDataType,6052,Variable diff --git a/src/Opc.Ua.Positioning/Model/Opc.Ua.GPOS.NodeSet2.xml b/src/Opc.Ua.Positioning/Model/Opc.Ua.GPOS.NodeSet2.xml new file mode 100644 index 0000000000..ba68926dbf --- /dev/null +++ b/src/Opc.Ua.Positioning/Model/Opc.Ua.GPOS.NodeSet2.xml @@ -0,0 +1,2706 @@ + + + + + + http://opcfoundation.org/UA/GPOS/ + http://opcfoundation.org/UA/RSL/ + + + + + + + + + i=1 + i=3 + i=7 + i=10 + i=11 + i=12 + i=13 + i=15 + i=17 + i=21 + i=35 + i=37 + i=38 + i=39 + i=40 + i=45 + i=46 + i=47 + i=256 + i=291 + i=7594 + i=18810 + i=18812 + ns=1;i=3003 + ns=1;i=3004 + ns=1;i=3005 + ns=1;i=3006 + + + + + + + + 3DGeographicCoordinateDataType + Represents a geographic coordinate + GPOS GlobalPosition + https://reference.opcfoundation.org/GPOS/v100/docs/9.1 + + i=22 + ns=1;i=5014 + ns=1;i=5015 + + + + MUST be interpreted according to the CoordinateReferenceSystem + + + MUST be interpreted according to the CoordinateReferenceSystem + + + MUST be interpreted according to the ElevationReference. If floor, the height relative to specific floor in meter + + + + + 3DGeographicCoordinateDataType + + i=69 + ns=1;i=5014 + ns=1;i=6001 + + + 3DGeographicCoordinateDataType + + + + 3DGeographicCoordinateDataType + + i=69 + ns=1;i=5015 + ns=1;i=6004 + + + //xs:element[@name='3DGeographicCoordinateDataType'] + + + + GlobalPositionDataType + Represents a global position + GPOS GlobalPosition + https://reference.opcfoundation.org/GPOS/v100/docs/9.2 + + ns=1;i=3003 + ns=1;i=5001 + ns=1;i=5002 + + + + The horizontal accuracy of the position in meters. + + + A logical and non-localized representation for a building floor. Floor 0 represents the floor designated as 'ground'. Negative numbers indicate floors below the ground floor and positive numbers indicate floors above the ground floor. When implemented, the floor value MUST match described logical numbering scheme, which can be different from any numbering used within a building. Values can be expressed as an integer value, or as a float as required for mezzanine floor levels. + + + + + GlobalPositionDataType + + i=69 + ns=1;i=5001 + ns=1;i=6001 + + + GlobalPositionDataType + + + + GlobalPositionDataType + + i=69 + ns=1;i=5002 + ns=1;i=6004 + + + //xs:element[@name='GlobalPositionDataType'] + + + + GlobalLocationDataType + Represents a global location + GPOS GlobalLocation + https://reference.opcfoundation.org/GPOS/v100/docs/9.3 + + i=22 + ns=1;i=5004 + ns=1;i=5005 + + + + + Informs about an orientation typically with respect to a position. In mathematics, orientation defines a geometric notion. + + + + + GlobalLocationDataType + + i=69 + ns=1;i=5004 + ns=1;i=6001 + + + GlobalLocationDataType + + + + GlobalLocationDataType + + i=69 + ns=1;i=5005 + ns=1;i=6004 + + + //xs:element[@name='GlobalLocationDataType'] + + + + GroundControlPointDataType + Defines a pair of coordinates - local and global - to allow geo-references from local coordinate to a global coordinate system + GPOS Zones + https://reference.opcfoundation.org/GPOS/v100/docs/9.4 + + i=22 + ns=1;i=5008 + ns=1;i=5009 + + + + Position in a global coordinate reference system + + + Local position in metric coordinates (in meter) + + + + + GroundControlPointDataType + + i=69 + ns=1;i=5008 + ns=1;i=6001 + + + GroundControlPointDataType + + + + GroundControlPointDataType + + i=69 + ns=1;i=5009 + ns=1;i=6004 + + + //xs:element[@name='GroundControlPointDataType'] + + + + TypeDictionary + Collects the data type descriptions of http://opcfoundation.org/UA/GPOS/ + + i=72 + i=93 + ns=1;i=6007 + ns=1;i=6003 + ns=1;i=6041 + ns=1;i=6018 + ns=1;i=6044 + ns=1;i=6002 + + + PG9wYzpUeXBlRGljdGlvbmFyeSB4bWxuczp1YT0iaHR0cDovL29wY2ZvdW5kYXRpb24ub3JnL1VBLyIgR + GVmYXVsdEJ5dGVPcmRlcj0iTGl0dGxlRW5kaWFuIiB4bWxuczp0bnM9Imh0dHA6Ly9vcGNmb + 3VuZGF0aW9uLm9yZy9VQS9HUE9TLyIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yM + DAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgVGFyZ2V0TmFtZXNwYWNlPSJodHRwOi8vb3BjZm91b + mRhdGlvbi5vcmcvVUEvR1BPUy8iIHhtbG5zOm9wYz0iaHR0cDovL29wY2ZvdW5kYXRpb24ub + 3JnL0JpbmFyeVNjaGVtYS8iPgogPG9wYzpJbXBvcnQgTmFtZXNwYWNlPSJodHRwOi8vb3BjZ + m91bmRhdGlvbi5vcmcvVUEvIi8+CiA8b3BjOlN0cnVjdHVyZWRUeXBlIEJhc2VUeXBlPSJ1Y + TpFeHRlbnNpb25PYmplY3QiIE5hbWU9IjNER2VvZ3JhcGhpY0Nvb3JkaW5hdGVEYXRhVHlwZ + SI+CiAgPG9wYzpEb2N1bWVudGF0aW9uPlJlcHJlc2VudHMgYSBnZW9ncmFwaGljIGNvb3Jka + W5hdGU8L29wYzpEb2N1bWVudGF0aW9uPgogIDxvcGM6RmllbGQgVHlwZU5hbWU9Im9wYzpCa + XQiIE5hbWU9IkVsZXZhdGlvblNwZWNpZmllZCIvPgogIDxvcGM6RmllbGQgTGVuZ3RoPSIzM + SIgVHlwZU5hbWU9Im9wYzpCaXQiIE5hbWU9IlJlc2VydmVkMSIvPgogIDxvcGM6RmllbGQgV + HlwZU5hbWU9Im9wYzpEb3VibGUiIE5hbWU9IkxvbmdpdHVkZSIvPgogIDxvcGM6RmllbGQgV + HlwZU5hbWU9Im9wYzpEb3VibGUiIE5hbWU9IkxhdGl0dWRlIi8+CiAgPG9wYzpGaWVsZCBTd + 2l0Y2hGaWVsZD0iRWxldmF0aW9uU3BlY2lmaWVkIiBUeXBlTmFtZT0ib3BjOkRvdWJsZSIgT + mFtZT0iRWxldmF0aW9uIi8+CiA8L29wYzpTdHJ1Y3R1cmVkVHlwZT4KIDxvcGM6U3RydWN0d + XJlZFR5cGUgQmFzZVR5cGU9InRuczozREdlb2dyYXBoaWNDb29yZGluYXRlRGF0YVR5cGUiI + E5hbWU9Ikdsb2JhbFBvc2l0aW9uRGF0YVR5cGUiPgogIDxvcGM6RG9jdW1lbnRhdGlvbj5SZ + XByZXNlbnRzIGEgZ2xvYmFsIHBvc2l0aW9uPC9vcGM6RG9jdW1lbnRhdGlvbj4KICA8b3BjO + kZpZWxkIFR5cGVOYW1lPSJvcGM6Qml0IiBOYW1lPSJFbGV2YXRpb25TcGVjaWZpZWQiLz4KI + CA8b3BjOkZpZWxkIFR5cGVOYW1lPSJvcGM6Qml0IiBOYW1lPSJBY2N1cmFjeVNwZWNpZmllZ + CIvPgogIDxvcGM6RmllbGQgVHlwZU5hbWU9Im9wYzpCaXQiIE5hbWU9IkZsb29yU3BlY2lma + WVkIi8+CiAgPG9wYzpGaWVsZCBMZW5ndGg9IjI5IiBUeXBlTmFtZT0ib3BjOkJpdCIgTmFtZ + T0iUmVzZXJ2ZWQxIi8+CiAgPG9wYzpGaWVsZCBUeXBlTmFtZT0ib3BjOkRvdWJsZSIgU291c + mNlVHlwZT0idG5zOjNER2VvZ3JhcGhpY0Nvb3JkaW5hdGVEYXRhVHlwZSIgTmFtZT0iTG9uZ + 2l0dWRlIi8+CiAgPG9wYzpGaWVsZCBUeXBlTmFtZT0ib3BjOkRvdWJsZSIgU291cmNlVHlwZ + T0idG5zOjNER2VvZ3JhcGhpY0Nvb3JkaW5hdGVEYXRhVHlwZSIgTmFtZT0iTGF0aXR1ZGUiL + z4KICA8b3BjOkZpZWxkIFN3aXRjaEZpZWxkPSJFbGV2YXRpb25TcGVjaWZpZWQiIFR5cGVOY + W1lPSJvcGM6RG91YmxlIiBTb3VyY2VUeXBlPSJ0bnM6M0RHZW9ncmFwaGljQ29vcmRpbmF0Z + URhdGFUeXBlIiBOYW1lPSJFbGV2YXRpb24iLz4KICA8b3BjOkZpZWxkIFN3aXRjaEZpZWxkP + SJBY2N1cmFjeVNwZWNpZmllZCIgVHlwZU5hbWU9Im9wYzpEb3VibGUiIE5hbWU9IkFjY3VyY + WN5Ii8+CiAgPG9wYzpGaWVsZCBTd2l0Y2hGaWVsZD0iRmxvb3JTcGVjaWZpZWQiIFR5cGVOY + W1lPSJvcGM6RmxvYXQiIE5hbWU9IkZsb29yIi8+CiA8L29wYzpTdHJ1Y3R1cmVkVHlwZT4KI + DxvcGM6U3RydWN0dXJlZFR5cGUgQmFzZVR5cGU9InVhOkV4dGVuc2lvbk9iamVjdCIgTmFtZ + T0iR2xvYmFsTG9jYXRpb25EYXRhVHlwZSI+CiAgPG9wYzpEb2N1bWVudGF0aW9uPlJlcHJlc + 2VudHMgYSBnbG9iYWwgbG9jYXRpb248L29wYzpEb2N1bWVudGF0aW9uPgogIDxvcGM6Rmllb + GQgVHlwZU5hbWU9Im9wYzpCaXQiIE5hbWU9Ik9yaWVudGF0aW9uU3BlY2lmaWVkIi8+CiAgP + G9wYzpGaWVsZCBMZW5ndGg9IjMxIiBUeXBlTmFtZT0ib3BjOkJpdCIgTmFtZT0iUmVzZXJ2Z + WQxIi8+CiAgPG9wYzpGaWVsZCBUeXBlTmFtZT0idG5zOkdsb2JhbFBvc2l0aW9uRGF0YVR5c + GUiIE5hbWU9IlBvc2l0aW9uIi8+CiAgPG9wYzpGaWVsZCBTd2l0Y2hGaWVsZD0iT3JpZW50Y + XRpb25TcGVjaWZpZWQiIFR5cGVOYW1lPSJ1YTozRE9yaWVudGF0aW9uIiBOYW1lPSJPcmllb + nRhdGlvbiIvPgogPC9vcGM6U3RydWN0dXJlZFR5cGU+CiA8b3BjOlN0cnVjdHVyZWRUeXBlI + EJhc2VUeXBlPSJ1YTpFeHRlbnNpb25PYmplY3QiIE5hbWU9Ikdyb3VuZENvbnRyb2xQb2lud + ERhdGFUeXBlIj4KICA8b3BjOkRvY3VtZW50YXRpb24+RGVmaW5lcyBhIHBhaXIgb2YgY29vc + mRpbmF0ZXMgLSBsb2NhbCBhbmQgZ2xvYmFsIC0gdG8gYWxsb3cgZ2VvLXJlZmVyZW5jZXMgZ + nJvbSBsb2NhbCBjb29yZGluYXRlIHRvIGEgZ2xvYmFsIGNvb3JkaW5hdGUgc3lzdGVtPC9vc + GM6RG9jdW1lbnRhdGlvbj4KICA8b3BjOkZpZWxkIFR5cGVOYW1lPSJ0bnM6M0RHZW9ncmFwa + GljQ29vcmRpbmF0ZURhdGFUeXBlIiBOYW1lPSJHbG9iYWxQb3NpdGlvbiIvPgogIDxvcGM6R + mllbGQgVHlwZU5hbWU9InVhOjNEQ2FydGVzaWFuQ29vcmRpbmF0ZXMiIE5hbWU9IkxvY2FsU + G9zaXRpb24iLz4KIDwvb3BjOlN0cnVjdHVyZWRUeXBlPgo8L29wYzpUeXBlRGljdGlvbmFye + T4K + + + + Deprecated + Indicates that all of the DataType definitions represented by the DataTypeDictionaryType are available through a DataTypeDefinition Attribute. + + i=68 + ns=1;i=6001 + + + true + + + + NamespaceUri + + i=68 + ns=1;i=6001 + + + http://opcfoundation.org/UA/GPOS/ + + + + TypeDictionary + Collects the data type descriptions of http://opcfoundation.org/UA/GPOS/ + + i=72 + i=92 + ns=1;i=6017 + ns=1;i=6006 + ns=1;i=6042 + ns=1;i=6040 + ns=1;i=6052 + ns=1;i=6005 + + + PHhzOnNjaGVtYSB4bWxuczp1YT0iaHR0cDovL29wY2ZvdW5kYXRpb24ub3JnL1VBLzIwMDgvMDIvVHlwZ + XMueHNkIiB4bWxuczp0bnM9Imh0dHA6Ly9vcGNmb3VuZGF0aW9uLm9yZy9VQS9HUE9TL1R5c + GVzLnhzZCIgdGFyZ2V0TmFtZXNwYWNlPSJodHRwOi8vb3BjZm91bmRhdGlvbi5vcmcvVUEvR + 1BPUy9UeXBlcy54c2QiIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNja + GVtYSIgZWxlbWVudEZvcm1EZWZhdWx0PSJxdWFsaWZpZWQiPgogPHhzOmltcG9ydCBuYW1lc + 3BhY2U9Imh0dHA6Ly9vcGNmb3VuZGF0aW9uLm9yZy9VQS8yMDA4LzAyL1R5cGVzLnhzZCIvP + gogPHhzOmNvbXBsZXhUeXBlIG5hbWU9IjNER2VvZ3JhcGhpY0Nvb3JkaW5hdGVEYXRhVHlwZ + SI+CiAgPHhzOmFubm90YXRpb24+CiAgIDx4czpkb2N1bWVudGF0aW9uPlJlcHJlc2VudHMgY + SBnZW9ncmFwaGljIGNvb3JkaW5hdGU8L3hzOmRvY3VtZW50YXRpb24+CiAgPC94czphbm5vd + GF0aW9uPgogIDx4czpzZXF1ZW5jZT4KICAgPHhzOmVsZW1lbnQgdHlwZT0ieHM6dW5zaWduZ + WRJbnQiIG1pbk9jY3Vycz0iMCIgbmFtZT0iRW5jb2RpbmdNYXNrIi8+CiAgIDx4czplbGVtZ + W50IHR5cGU9InhzOmRvdWJsZSIgbWluT2NjdXJzPSIwIiBtYXhPY2N1cnM9IjEiIG5hbWU9I + kxvbmdpdHVkZSIvPgogICA8eHM6ZWxlbWVudCB0eXBlPSJ4czpkb3VibGUiIG1pbk9jY3Vyc + z0iMCIgbWF4T2NjdXJzPSIxIiBuYW1lPSJMYXRpdHVkZSIvPgogICA8eHM6ZWxlbWVudCB0e + XBlPSJ4czpkb3VibGUiIG1pbk9jY3Vycz0iMCIgbWF4T2NjdXJzPSIxIiBuYW1lPSJFbGV2Y + XRpb24iLz4KICA8L3hzOnNlcXVlbmNlPgogPC94czpjb21wbGV4VHlwZT4KIDx4czplbGVtZ + W50IHR5cGU9InRuczozREdlb2dyYXBoaWNDb29yZGluYXRlRGF0YVR5cGUiIG5hbWU9IjNER + 2VvZ3JhcGhpY0Nvb3JkaW5hdGVEYXRhVHlwZSIvPgogPHhzOmNvbXBsZXhUeXBlIG5hbWU9I + kxpc3RPZjNER2VvZ3JhcGhpY0Nvb3JkaW5hdGVEYXRhVHlwZSI+CiAgPHhzOnNlcXVlbmNlP + gogICA8eHM6ZWxlbWVudCBuaWxsYWJsZT0idHJ1ZSIgdHlwZT0idG5zOjNER2VvZ3JhcGhpY + 0Nvb3JkaW5hdGVEYXRhVHlwZSIgbWluT2NjdXJzPSIwIiBtYXhPY2N1cnM9InVuYm91bmRlZ + CIgbmFtZT0iM0RHZW9ncmFwaGljQ29vcmRpbmF0ZURhdGFUeXBlIi8+CiAgPC94czpzZXF1Z + W5jZT4KIDwveHM6Y29tcGxleFR5cGU+CiA8eHM6ZWxlbWVudCBuaWxsYWJsZT0idHJ1ZSIgd + HlwZT0idG5zOkxpc3RPZjNER2VvZ3JhcGhpY0Nvb3JkaW5hdGVEYXRhVHlwZSIgbmFtZT0iT + GlzdE9mM0RHZW9ncmFwaGljQ29vcmRpbmF0ZURhdGFUeXBlIi8+CiA8eHM6Y29tcGxleFR5c + GUgbmFtZT0iR2xvYmFsUG9zaXRpb25EYXRhVHlwZSI+CiAgPHhzOmFubm90YXRpb24+CiAgI + Dx4czpkb2N1bWVudGF0aW9uPlJlcHJlc2VudHMgYSBnbG9iYWwgcG9zaXRpb248L3hzOmRvY + 3VtZW50YXRpb24+CiAgPC94czphbm5vdGF0aW9uPgogIDx4czpjb21wbGV4Q29udGVudCBta + XhlZD0iZmFsc2UiPgogICA8eHM6ZXh0ZW5zaW9uIGJhc2U9InRuczozREdlb2dyYXBoaWNDb + 29yZGluYXRlRGF0YVR5cGUiPgogICAgPHhzOnNlcXVlbmNlPgogICAgIDx4czplbGVtZW50I + HR5cGU9InhzOmRvdWJsZSIgbWluT2NjdXJzPSIwIiBtYXhPY2N1cnM9IjEiIG5hbWU9IkFjY + 3VyYWN5Ii8+CiAgICAgPHhzOmVsZW1lbnQgdHlwZT0ieHM6ZmxvYXQiIG1pbk9jY3Vycz0iM + CIgbWF4T2NjdXJzPSIxIiBuYW1lPSJGbG9vciIvPgogICAgPC94czpzZXF1ZW5jZT4KICAgP + C94czpleHRlbnNpb24+CiAgPC94czpjb21wbGV4Q29udGVudD4KIDwveHM6Y29tcGxleFR5c + GU+CiA8eHM6ZWxlbWVudCB0eXBlPSJ0bnM6R2xvYmFsUG9zaXRpb25EYXRhVHlwZSIgbmFtZ + T0iR2xvYmFsUG9zaXRpb25EYXRhVHlwZSIvPgogPHhzOmNvbXBsZXhUeXBlIG5hbWU9Ikxpc + 3RPZkdsb2JhbFBvc2l0aW9uRGF0YVR5cGUiPgogIDx4czpzZXF1ZW5jZT4KICAgPHhzOmVsZ + W1lbnQgbmlsbGFibGU9InRydWUiIHR5cGU9InRuczpHbG9iYWxQb3NpdGlvbkRhdGFUeXBlI + iBtaW5PY2N1cnM9IjAiIG1heE9jY3Vycz0idW5ib3VuZGVkIiBuYW1lPSJHbG9iYWxQb3Npd + GlvbkRhdGFUeXBlIi8+CiAgPC94czpzZXF1ZW5jZT4KIDwveHM6Y29tcGxleFR5cGU+CiA8e + HM6ZWxlbWVudCBuaWxsYWJsZT0idHJ1ZSIgdHlwZT0idG5zOkxpc3RPZkdsb2JhbFBvc2l0a + W9uRGF0YVR5cGUiIG5hbWU9Ikxpc3RPZkdsb2JhbFBvc2l0aW9uRGF0YVR5cGUiLz4KIDx4c + zpjb21wbGV4VHlwZSBuYW1lPSJHbG9iYWxMb2NhdGlvbkRhdGFUeXBlIj4KICA8eHM6YW5ub + 3RhdGlvbj4KICAgPHhzOmRvY3VtZW50YXRpb24+UmVwcmVzZW50cyBhIGdsb2JhbCBsb2Nhd + GlvbjwveHM6ZG9jdW1lbnRhdGlvbj4KICA8L3hzOmFubm90YXRpb24+CiAgPHhzOnNlcXVlb + mNlPgogICA8eHM6ZWxlbWVudCB0eXBlPSJ4czp1bnNpZ25lZEludCIgbWluT2NjdXJzPSIwI + iBuYW1lPSJFbmNvZGluZ01hc2siLz4KICAgPHhzOmVsZW1lbnQgdHlwZT0idG5zOkdsb2Jhb + FBvc2l0aW9uRGF0YVR5cGUiIG1pbk9jY3Vycz0iMCIgbWF4T2NjdXJzPSIxIiBuYW1lPSJQb + 3NpdGlvbiIvPgogICA8eHM6ZWxlbWVudCB0eXBlPSJ1YTozRE9yaWVudGF0aW9uIiBtaW5PY + 2N1cnM9IjAiIG1heE9jY3Vycz0iMSIgbmFtZT0iT3JpZW50YXRpb24iLz4KICA8L3hzOnNlc + XVlbmNlPgogPC94czpjb21wbGV4VHlwZT4KIDx4czplbGVtZW50IHR5cGU9InRuczpHbG9iY + WxMb2NhdGlvbkRhdGFUeXBlIiBuYW1lPSJHbG9iYWxMb2NhdGlvbkRhdGFUeXBlIi8+CiA8e + HM6Y29tcGxleFR5cGUgbmFtZT0iTGlzdE9mR2xvYmFsTG9jYXRpb25EYXRhVHlwZSI+CiAgP + HhzOnNlcXVlbmNlPgogICA8eHM6ZWxlbWVudCBuaWxsYWJsZT0idHJ1ZSIgdHlwZT0idG5zO + kdsb2JhbExvY2F0aW9uRGF0YVR5cGUiIG1pbk9jY3Vycz0iMCIgbWF4T2NjdXJzPSJ1bmJvd + W5kZWQiIG5hbWU9Ikdsb2JhbExvY2F0aW9uRGF0YVR5cGUiLz4KICA8L3hzOnNlcXVlbmNlP + gogPC94czpjb21wbGV4VHlwZT4KIDx4czplbGVtZW50IG5pbGxhYmxlPSJ0cnVlIiB0eXBlP + SJ0bnM6TGlzdE9mR2xvYmFsTG9jYXRpb25EYXRhVHlwZSIgbmFtZT0iTGlzdE9mR2xvYmFsT + G9jYXRpb25EYXRhVHlwZSIvPgogPHhzOmNvbXBsZXhUeXBlIG5hbWU9Ikdyb3VuZENvbnRyb + 2xQb2ludERhdGFUeXBlIj4KICA8eHM6YW5ub3RhdGlvbj4KICAgPHhzOmRvY3VtZW50YXRpb + 24+RGVmaW5lcyBhIHBhaXIgb2YgY29vcmRpbmF0ZXMgLSBsb2NhbCBhbmQgZ2xvYmFsIC0gd + G8gYWxsb3cgZ2VvLXJlZmVyZW5jZXMgZnJvbSBsb2NhbCBjb29yZGluYXRlIHRvIGEgZ2xvY + mFsIGNvb3JkaW5hdGUgc3lzdGVtPC94czpkb2N1bWVudGF0aW9uPgogIDwveHM6YW5ub3Rhd + Glvbj4KICA8eHM6c2VxdWVuY2U+CiAgIDx4czplbGVtZW50IHR5cGU9InRuczozREdlb2dyY + XBoaWNDb29yZGluYXRlRGF0YVR5cGUiIG1pbk9jY3Vycz0iMCIgbWF4T2NjdXJzPSIxIiBuY + W1lPSJHbG9iYWxQb3NpdGlvbiIvPgogICA8eHM6ZWxlbWVudCB0eXBlPSJ1YTozRENhcnRlc + 2lhbkNvb3JkaW5hdGVzIiBtaW5PY2N1cnM9IjAiIG1heE9jY3Vycz0iMSIgbmFtZT0iTG9jY + WxQb3NpdGlvbiIvPgogIDwveHM6c2VxdWVuY2U+CiA8L3hzOmNvbXBsZXhUeXBlPgogPHhzO + mVsZW1lbnQgdHlwZT0idG5zOkdyb3VuZENvbnRyb2xQb2ludERhdGFUeXBlIiBuYW1lPSJHc + m91bmRDb250cm9sUG9pbnREYXRhVHlwZSIvPgogPHhzOmNvbXBsZXhUeXBlIG5hbWU9Ikxpc + 3RPZkdyb3VuZENvbnRyb2xQb2ludERhdGFUeXBlIj4KICA8eHM6c2VxdWVuY2U+CiAgIDx4c + zplbGVtZW50IG5pbGxhYmxlPSJ0cnVlIiB0eXBlPSJ0bnM6R3JvdW5kQ29udHJvbFBvaW50R + GF0YVR5cGUiIG1pbk9jY3Vycz0iMCIgbWF4T2NjdXJzPSJ1bmJvdW5kZWQiIG5hbWU9Ikdyb + 3VuZENvbnRyb2xQb2ludERhdGFUeXBlIi8+CiAgPC94czpzZXF1ZW5jZT4KIDwveHM6Y29tc + GxleFR5cGU+CiA8eHM6ZWxlbWVudCBuaWxsYWJsZT0idHJ1ZSIgdHlwZT0idG5zOkxpc3RPZ + kdyb3VuZENvbnRyb2xQb2ludERhdGFUeXBlIiBuYW1lPSJMaXN0T2ZHcm91bmRDb250cm9sU + G9pbnREYXRhVHlwZSIvPgo8L3hzOnNjaGVtYT4K + + + + Deprecated + Indicates that all of the DataType definitions represented by the DataTypeDictionaryType are available through a DataTypeDefinition Attribute. + + i=68 + ns=1;i=6004 + + + true + + + + NamespaceUri + + i=68 + ns=1;i=6004 + + + http://opcfoundation.org/UA/GPOS/Types.xsd + + + + GlobalPositionType + Describes a point (i.e. a position) in 2 or 3 dimensions. Important: A Point object shall be interpreted according to the CoordinateReferenceSystem. + GPOS GlobalPosition + https://reference.opcfoundation.org/GPOS/v100/docs/8.1 + + i=63 + ns=1;i=6028 + ns=1;i=6033 + ns=1;i=6030 + ns=1;i=6049 + ns=1;i=6012 + ns=1;i=6029 + ns=1;i=6027 + ns=1;i=6038 + + + + Accuracy + The horizontal accuracy of the position in meters. + + i=80 + i=63 + ns=1;i=2006 + + + + CoordinateReferenceSystem + A projection identifier defining the projection of the provided location coordinate. The CoordinateReferenceSystem shall be either a valid EPSG identifier (https://epsg.io) or 'local' if the location is provided as a relative coordinate of the floor plan. For best interoperability and worldwide coverage, WGS84 (EPSG:4326) should be the preferred projection (as used also by GPS). + + i=78 + i=11238 + ns=1;i=2006 + ns=1;i=6034 + ns=1;i=6035 + + + + EnumValues + + i=68 + i=78 + ns=1;i=6033 + + + + + + i=7616 + + + + 0 + + local + + + + + + + + i=7616 + + + + 4326 + + GPS + + + + + + + + i=7616 + + + + 32601 + + UTM zone 1N + + + + + + + + i=7616 + + + + 32602 + + UTM zone 2N + + + + + + + + i=7616 + + + + 32603 + + UTM zone 3N + + + + + + + + i=7616 + + + + 32604 + + UTM zone 4N + + + + + + + + i=7616 + + + + 32605 + + UTM zone 5N + + + + + + + + i=7616 + + + + 32606 + + UTM zone 6N + + + + + + + + i=7616 + + + + 32607 + + UTM zone 7N + + + + + + + + i=7616 + + + + 32608 + + UTM zone 8N + + + + + + + + i=7616 + + + + 32609 + + UTM zone 9N + + + + + + + + i=7616 + + + + 32610 + + UTM zone 10N + + + + + + + + i=7616 + + + + 32611 + + UTM zone 11N + + + + + + + + i=7616 + + + + 32612 + + UTM zone 12N + + + + + + + + i=7616 + + + + 32613 + + UTM zone 13N + + + + + + + + i=7616 + + + + 32614 + + UTM zone 14N + + + + + + + + i=7616 + + + + 32615 + + UTM zone 15N + + + + + + + + i=7616 + + + + 32616 + + UTM zone 16N + + + + + + + + i=7616 + + + + 32617 + + UTM zone 17N + + + + + + + + i=7616 + + + + 32618 + + UTM zone 18N + + + + + + + + i=7616 + + + + 32619 + + UTM zone 19N + + + + + + + + i=7616 + + + + 32620 + + UTM zone 20N + + + + + + + + i=7616 + + + + 32621 + + UTM zone 21N + + + + + + + + i=7616 + + + + 32622 + + UTM zone 22N + + + + + + + + i=7616 + + + + 32623 + + UTM zone 23N + + + + + + + + i=7616 + + + + 32624 + + UTM zone 24N + + + + + + + + i=7616 + + + + 32625 + + UTM zone 25N + + + + + + + + i=7616 + + + + 32626 + + UTM zone 26N + + + + + + + + i=7616 + + + + 32627 + + UTM zone 27N + + + + + + + + i=7616 + + + + 32628 + + UTM zone 28N + + + + + + + + i=7616 + + + + 32629 + + UTM zone 29N + + + + + + + + i=7616 + + + + 32630 + + UTM zone 30N + + + + + + + + i=7616 + + + + 32631 + + UTM zone 31N + + + + + + + + i=7616 + + + + 32632 + + UTM zone 32N + + + + + + + + i=7616 + + + + 32633 + + UTM zone 33N + + + + + + + + i=7616 + + + + 32634 + + UTM zone 34N + + + + + + + + i=7616 + + + + 32635 + + UTM zone 35N + + + + + + + + i=7616 + + + + 32636 + + UTM zone 36N + + + + + + + + i=7616 + + + + 32637 + + UTM zone 37N + + + + + + + + i=7616 + + + + 32638 + + UTM zone 38N + + + + + + + + i=7616 + + + + 32639 + + UTM zone 39N + + + + + + + + i=7616 + + + + 32640 + + UTM zone 40N + + + + + + + + i=7616 + + + + 32641 + + UTM zone 41N + + + + + + + + i=7616 + + + + 32642 + + UTM zone 42N + + + + + + + + i=7616 + + + + 32643 + + UTM zone 43N + + + + + + + + i=7616 + + + + 32644 + + UTM zone 44N + + + + + + + + i=7616 + + + + 32645 + + UTM zone 45N + + + + + + + + i=7616 + + + + 32646 + + UTM zone 46N + + + + + + + + i=7616 + + + + 32647 + + UTM zone 47N + + + + + + + + i=7616 + + + + 32648 + + UTM zone 48N + + + + + + + + i=7616 + + + + 32649 + + UTM zone 49N + + + + + + + + i=7616 + + + + 32650 + + UTM zone 50N + + + + + + + + i=7616 + + + + 32651 + + UTM zone 51N + + + + + + + + i=7616 + + + + 32652 + + UTM zone 52N + + + + + + + + i=7616 + + + + 32653 + + UTM zone 53N + + + + + + + + i=7616 + + + + 32654 + + UTM zone 54N + + + + + + + + i=7616 + + + + 32655 + + UTM zone 55N + + + + + + + + i=7616 + + + + 32656 + + UTM zone 56N + + + + + + + + i=7616 + + + + 32657 + + UTM zone 57N + + + + + + + + i=7616 + + + + 32658 + + UTM zone 58N + + + + + + + + i=7616 + + + + 32659 + + UTM zone 59N + + + + + + + + i=7616 + + + + 32661 + + UPS North (N,E) + + + + + + + + i=7616 + + + + 32701 + + UTM zone 1S + + + + + + + + i=7616 + + + + 32702 + + UTM zone 2S + + + + + + + + i=7616 + + + + 32703 + + UTM zone 3S + + + + + + + + i=7616 + + + + 32704 + + UTM zone 4S + + + + + + + + i=7616 + + + + 32705 + + UTM zone 5S + + + + + + + + i=7616 + + + + 32706 + + UTM zone 6S + + + + + + + + i=7616 + + + + 32707 + + UTM zone 7S + + + + + + + + i=7616 + + + + 32708 + + UTM zone 8S + + + + + + + + i=7616 + + + + 32709 + + UTM zone 9S + + + + + + + + i=7616 + + + + 32710 + + UTM zone 10S + + + + + + + + i=7616 + + + + 32711 + + UTM zone 11S + + + + + + + + i=7616 + + + + 32712 + + UTM zone 12S + + + + + + + + i=7616 + + + + 32713 + + UTM zone 13S + + + + + + + + i=7616 + + + + 32714 + + UTM zone 14S + + + + + + + + i=7616 + + + + 32715 + + UTM zone 15S + + + + + + + + i=7616 + + + + 32716 + + UTM zone 16S + + + + + + + + i=7616 + + + + 32717 + + UTM zone 17S + + + + + + + + i=7616 + + + + 32718 + + UTM zone 18S + + + + + + + + i=7616 + + + + 32719 + + UTM zone 19S + + + + + + + + i=7616 + + + + 32720 + + UTM zone 20S + + + + + + + + i=7616 + + + + 32721 + + UTM zone 21S + + + + + + + + i=7616 + + + + 32722 + + UTM zone 22S + + + + + + + + i=7616 + + + + 32723 + + UTM zone 23S + + + + + + + + i=7616 + + + + 32724 + + UTM zone 24S + + + + + + + + i=7616 + + + + 32725 + + UTM zone 25S + + + + + + + + i=7616 + + + + 32726 + + UTM zone 26S + + + + + + + + i=7616 + + + + 32727 + + UTM zone 27S + + + + + + + + i=7616 + + + + 32728 + + UTM zone 28S + + + + + + + + i=7616 + + + + 32729 + + UTM zone 29S + + + + + + + + i=7616 + + + + 32730 + + UTM zone 30S + + + + + + + + i=7616 + + + + 32731 + + UTM zone 31S + + + + + + + + i=7616 + + + + 32732 + + UTM zone 32S + + + + + + + + i=7616 + + + + 32733 + + UTM zone 33S + + + + + + + + i=7616 + + + + 32734 + + UTM zone 34S + + + + + + + + i=7616 + + + + 32735 + + UTM zone 35S + + + + + + + + i=7616 + + + + 32736 + + UTM zone 36S + + + + + + + + i=7616 + + + + 32737 + + UTM zone 37S + + + + + + + + i=7616 + + + + 32738 + + UTM zone 38S + + + + + + + + i=7616 + + + + 32739 + + UTM zone 39S + + + + + + + + i=7616 + + + + 32740 + + UTM zone 40S + + + + + + + + i=7616 + + + + 32741 + + UTM zone 41S + + + + + + + + i=7616 + + + + 32742 + + UTM zone 42S + + + + + + + + i=7616 + + + + 32743 + + UTM zone 43S + + + + + + + + i=7616 + + + + 32744 + + UTM zone 44S + + + + + + + + i=7616 + + + + 32745 + + UTM zone 45S + + + + + + + + i=7616 + + + + 32746 + + UTM zone 46S + + + + + + + + i=7616 + + + + 32747 + + UTM zone 47S + + + + + + + + i=7616 + + + + 32748 + + UTM zone 48S + + + + + + + + i=7616 + + + + 32749 + + UTM zone 49S + + + + + + + + i=7616 + + + + 32750 + + UTM zone 50S + + + + + + + + i=7616 + + + + 32751 + + UTM zone 51S + + + + + + + + i=7616 + + + + 32752 + + UTM zone 52S + + + + + + + + i=7616 + + + + 32753 + + UTM zone 53S + + + + + + + + i=7616 + + + + 32754 + + UTM zone 54S + + + + + + + + i=7616 + + + + 32755 + + UTM zone 55S + + + + + + + + i=7616 + + + + 32756 + + UTM zone 56S + + + + + + + + i=7616 + + + + 32757 + + UTM zone 57S + + + + + + + + i=7616 + + + + 32758 + + UTM zone 58S + + + + + + + + i=7616 + + + + 32759 + + UTM zone 59S + + + + + + + + i=7616 + + + + 32761 + + UPS South (N,E) + + + + + + + + + + ValueAsText + + i=78 + i=68 + ns=1;i=6033 + + + + Elevation + MUST be interpreted according to the ElevationReference. If floor, the height relative to specific floor in meter + + i=80 + i=63 + ns=1;i=2006 + + + + ElevationReference + An elevation reference hint for the position's Elevation. If present it shall be either 'floor' or 'wgs84'. If set to 'floor' the Elevation shall be assumed to be relative to the floor level in meter. If set to 'wgs84' the Elevation shall be treated as WGS84 ellipsoidal height. For the majority of applications an accurate geographic height may not be available. Therefore ElevationReference shall be assumed 'floor' by default if it is not present. + + i=80 + i=11238 + ns=1;i=2006 + ns=1;i=6050 + ns=1;i=6051 + + + + EnumValues + + i=68 + i=78 + ns=1;i=6049 + + + + + + i=7616 + + + + 0 + + floor + + + + + + + + i=7616 + + + + 1 + + wgs84 + + + + + + + + + + ValueAsText + + i=78 + i=68 + ns=1;i=6049 + + + + Floor + A logical and non-localized representation for a building floor. Floor 0 represents the floor designated as 'ground'. Negative numbers indicate floors below the ground floor and positive numbers indicate floors above the ground floor. When implemented, the floor value MUST match described logical numbering scheme, which can be different from any numbering used within a building. Values can be expressed as an integer value, or as a float as required for mezzanine floor levels. + + i=80 + i=63 + ns=1;i=2006 + + + + Latitude + MUST be interpreted according to the CoordinateReferenceSystem + + i=78 + i=63 + ns=1;i=2006 + + + + Longitude + MUST be interpreted according to the CoordinateReferenceSystem + + i=78 + i=63 + ns=1;i=2006 + + + + SourceId + Reference to the zone or provider calculating the position + + i=68 + i=78 + ns=1;i=2006 + + + + GlobalLocationType + Defines the location of an object - in a global coordinate reference system + GPOS GlobalLocation + https://reference.opcfoundation.org/GPOS/v100/docs/8.2 + + ns=2;i=2003 + ns=1;i=6008 + ns=1;i=6009 + + + + Orientation + Informs about an orientation typically with respect to a position. In mathematics, orientation defines a geometric notion. + + i=18781 + i=80 + ns=1;i=2003 + ns=1;i=6014 + ns=1;i=6015 + ns=1;i=6016 + + + + A + + i=63 + i=78 + ns=1;i=6008 + + + + B + + i=63 + i=78 + ns=1;i=6008 + + + + C + + i=63 + i=78 + ns=1;i=6008 + + + + Position + + i=78 + ns=1;i=2006 + ns=1;i=2003 + ns=1;i=6036 + ns=1;i=6032 + ns=1;i=6031 + ns=1;i=6039 + + + + CoordinateReferenceSystem + A projection identifier defining the projection of the provided location coordinate. The CoordinateReferenceSystem shall be either a valid EPSG identifier (https://epsg.io) or 'local' if the location is provided as a relative coordinate of the floor plan. For best interoperability and worldwide coverage, WGS84 (EPSG:4326) should be the preferred projection (as used also by GPS). + + i=11238 + i=78 + ns=1;i=6009 + ns=1;i=6037 + ns=1;i=6048 + + + + EnumValues + + i=68 + i=78 + ns=1;i=6036 + + + + + + i=7616 + + + + 0 + + local + + + + + + + + i=7616 + + + + 4326 + + GPS + + + + + + + + + + ValueAsText + + i=68 + i=78 + ns=1;i=6036 + + + + Latitude + MUST be interpreted according to the CoordinateReferenceSystem + + i=63 + i=78 + ns=1;i=6009 + + + + Longitude + MUST be interpreted according to the CoordinateReferenceSystem + + i=63 + i=78 + ns=1;i=6009 + + + + SourceId + Reference to the zone or provider calculating the position + + i=78 + i=68 + ns=1;i=6009 + + + + ZoneType + Defines an area, in which a local location is computed, e.g. via a RTLS or an proximity based system like RFID. For that reason it might have a set of GroundControlPoints defining the extent or just a point and a radius. + GPOS Zones + https://reference.opcfoundation.org/GPOS/v100/docs/7.1.2 + + i=58 + ns=1;i=6010 + ns=1;i=6011 + ns=1;i=6047 + ns=1;i=6013 + ns=1;i=6045 + ns=1;i=6046 + ns=1;i=6026 + ns=1;i=6043 + + + + Building + Name of the building + + i=68 + i=80 + ns=1;i=1005 + + + + Floor + Name of the floor + + i=68 + i=80 + ns=1;i=1005 + + + + GroundControlPoints + Points defining the extent of the zone + + i=63 + i=80 + ns=1;i=1005 + + + + IncompleteConfiguration + Indicates if the configuration is complete. FALSE if configuration is complete. + + i=68 + i=80 + ns=1;i=1005 + + + + Position + Describes a point (i.e. a position) in 2 or 3 dimensions. + + i=63 + i=80 + ns=1;i=1005 + + + + Radius + For proximity-based systems. Describes the zone as a circular region with a given radius in meters around the position. + + i=63 + i=80 + ns=1;i=1005 + + + + Site + Name of the site + + i=68 + i=80 + ns=1;i=1005 + + + + ZoneId + A unique identifier of the zone (e.g. a GUID). + + i=78 + i=68 + ns=1;i=1005 + + + + GlobalLocations + GPOS Zones + https://reference.opcfoundation.org/GPOS/v100/docs/10.1 + + i=61 + i=31915 + + + + http://opcfoundation.org/UA/GPOS/ + https://reference.opcfoundation.org/GPOS/v100/docs/12.1 + + i=11616 + ns=1;i=6019 + ns=1;i=6020 + ns=1;i=6021 + ns=1;i=6022 + ns=1;i=6023 + ns=1;i=6024 + ns=1;i=6025 + i=11715 + + + + IsNamespaceSubset + + i=68 + ns=1;i=5007 + + + false + + + + NamespacePublicationDate + + i=68 + ns=1;i=5007 + + + 2025-09-25T00:00:00Z + + + + NamespaceUri + + i=68 + ns=1;i=5007 + + + http://opcfoundation.org/UA/GPOS/ + + + + NamespaceVersion + + i=68 + ns=1;i=5007 + + + 1.0.0 + + + + StaticNodeIdTypes + + i=68 + ns=1;i=5007 + + + + 0 + + + + + StaticNumericNodeIdRange + + i=68 + ns=1;i=5007 + + + + + + + StaticStringNodeIdPattern + + i=68 + ns=1;i=5007 + + + + + + + Default Binary + + i=76 + ns=1;i=3006 + ns=1;i=6018 + + + + Default XML + + i=76 + ns=1;i=3006 + ns=1;i=6040 + + + + Default Binary + + i=76 + ns=1;i=3004 + ns=1;i=6041 + + + + Default XML + + i=76 + ns=1;i=3004 + ns=1;i=6042 + + + + Default Binary + + i=76 + ns=1;i=3005 + ns=1;i=6044 + + + + Default XML + + i=76 + ns=1;i=3005 + ns=1;i=6052 + + + + Default Binary + + i=76 + ns=1;i=3003 + ns=1;i=6007 + + + + Default XML + + i=76 + ns=1;i=3003 + ns=1;i=6017 + + + diff --git a/src/Opc.Ua.Positioning/Model/Opc.Ua.RSL.NodeSet2.csv b/src/Opc.Ua.Positioning/Model/Opc.Ua.RSL.NodeSet2.csv new file mode 100644 index 0000000000..f3a16447ef --- /dev/null +++ b/src/Opc.Ua.Positioning/Model/Opc.Ua.RSL.NodeSet2.csv @@ -0,0 +1,54 @@ +SpatialObjectType,1002,ObjectType +SpatialObjectsListType,1003,ObjectType +RelativeValueType,2002,VariableType +SpatialLocationType,2003,VariableType +CartesianFrameAngleOrientationType,2004,VariableType +RpyOrientationType,2005,VariableType +RelativeSpatialLocations,5001,Object +SpatialObjectType_AttachPoints,5002,Object +SpatialObjectType_InternalFrames,5003,Object +SpatialObjectType_AlternativeFrames,5004,Object +Server_Namespaces_http___opcfoundation_org_UA_RSL_,5005,Object +SpatialObjectsListType_SpatialObject,5006,Object +RelativeValueType_Base,6001,Variable +SpatialLocationType_Base,6002,Variable +SpatialLocationType_Position,6003,Variable +SpatialLocationType_Orientation,6004,Variable +CartesianFrameAngleOrientationType_Base,6005,Variable +CartesianFrameAngleOrientationType_Orientation,6006,Variable +CartesianFrameAngleOrientationType_Position,6007,Variable +CartesianFrameAngleOrientationType_Position_X,6011,Variable +CartesianFrameAngleOrientationType_Position_Y,6012,Variable +CartesianFrameAngleOrientationType_Position_Z,6013,Variable +SpatialObjectType_PositionFrame,6014,Variable +SpatialObjectType_PositionFrame_Base,6015,Variable +SpatialObjectType_Identifier,6016,Variable +SpatialObjectType_DefaultInstanceBrowseName,6017,Variable +SpatialObjectType_AttachPoints_FrameIdentifier,6018,Variable +SpatialObjectType_AttachPoints_FrameIdentifier_Base,6019,Variable +SpatialObjectType_AlternativeFrames_FrameIdentifier,6020,Variable +SpatialObjectType_AlternativeFrames_FrameIdentifier_Base,6021,Variable +SpatialObjectType_InternalFrames_FrameIdentifier,6022,Variable +SpatialObjectType_InternalFrames_FrameIdentifier_Base,6023,Variable +SpatialObjectsListType_Identifier,6024,Variable +SpatialObjectsListType_WorldFrame,6025,Variable +SpatialObjectsListType_WorldFrame_Base,6026,Variable +SpatialObjectsListType_NodeVersion,6027,Variable +Server_Namespaces_http___opcfoundation_org_UA_RSL__IsNamespaceSubset,6028,Variable +Server_Namespaces_http___opcfoundation_org_UA_RSL__NamespacePublicationDate,6029,Variable +Server_Namespaces_http___opcfoundation_org_UA_RSL__NamespaceUri,6030,Variable +Server_Namespaces_http___opcfoundation_org_UA_RSL__NamespaceVersion,6031,Variable +Server_Namespaces_http___opcfoundation_org_UA_RSL__StaticNodeIdTypes,6032,Variable +Server_Namespaces_http___opcfoundation_org_UA_RSL__StaticNumericNodeIdRange,6033,Variable +Server_Namespaces_http___opcfoundation_org_UA_RSL__StaticStringNodeIdPattern,6034,Variable +SpatialObjectsListType_SpatialObject_DefaultInstanceBrowseName,6035,Variable +CartesianFrameAngleOrientationType_Orientation_AngleUnit,6036,Variable +RpyOrientationType_A,6037,Variable +SpatialObjectsListType_SpatialObject_PositionFrame,6038,Variable +SpatialObjectsListType_SpatialObject_PositionFrame_Base,6039,Variable +RpyOrientationType_B,6040,Variable +RpyOrientationType_C,6041,Variable +CartesianFrameAngleOrientationType_Orientation_A,6042,Variable +CartesianFrameAngleOrientationType_Orientation_B,6043,Variable +CartesianFrameAngleOrientationType_Orientation_C,6044,Variable +CartesianFrameAngleOrientationType_Position_LengthUnit,6045,Variable diff --git a/src/Opc.Ua.Positioning/Model/Opc.Ua.RSL.NodeSet2.xml b/src/Opc.Ua.Positioning/Model/Opc.Ua.RSL.NodeSet2.xml new file mode 100644 index 0000000000..682e637ac6 --- /dev/null +++ b/src/Opc.Ua.Positioning/Model/Opc.Ua.RSL.NodeSet2.xml @@ -0,0 +1,601 @@ + + + + + + http://opcfoundation.org/UA/RSL/ + + + + + + + + i=1 + i=11 + i=12 + i=13 + i=17 + i=20 + i=35 + i=37 + i=40 + i=41 + i=45 + i=46 + i=47 + i=256 + i=291 + i=887 + i=18810 + i=18812 + i=18814 + + + + + + + + RpyOrientationType + CartesianFrameAngleOrientationType + https://reference.opcfoundation.org/RSL/v100/docs/7.4 + + ns=1;i=6037 + ns=1;i=6040 + ns=1;i=6041 + i=18779 + + + + + i=18857 + + + + 0 + 0 + 0 + + + + + + + A + Rotation around X Axis (Roll) as per ISO 9787:2013 + + i=63 + i=78 + ns=1;i=2005 + + + + B + Rotation around Y Axis (Pitch) as per ISO 9787:2013 + + i=63 + i=78 + ns=1;i=2005 + + + + C + Rotation around Z Axis (Yaw) as per ISO 9787:2013 + + i=63 + i=78 + ns=1;i=2005 + + + + RelativeValueType + https://reference.opcfoundation.org/RSL/v100/docs/7.1 + + ns=1;i=6001 + i=63 + + + + Base + + i=63 + i=80 + ns=1;i=2002 + + + + SpatialLocationType + https://reference.opcfoundation.org/RSL/v100/docs/7.2 + + ns=1;i=6002 + ns=1;i=6004 + ns=1;i=6003 + ns=1;i=2002 + + + + Base + + i=63 + i=78 + ns=1;i=2003 + + + + Orientation + + i=63 + i=80 + ns=1;i=2003 + + + + Position + + i=63 + i=80 + ns=1;i=2003 + + + + CartesianFrameAngleOrientationType + CartesianFrameAngleOrientationType + https://reference.opcfoundation.org/RSL/v100/docs/7.3 + + ns=1;i=6005 + ns=1;i=2003 + ns=1;i=6006 + ns=1;i=6007 + + + + Base + + i=63 + i=78 + ns=1;i=2004 + + + + Orientation + + i=78 + ns=1;i=2005 + ns=1;i=6042 + ns=1;i=6036 + ns=1;i=6043 + ns=1;i=6044 + ns=1;i=2004 + + + + + i=18857 + + + + 0 + 0 + 0 + + + + + + + A + Rotation around X Axis (Roll) as per ISO 9787:2013 + + i=63 + i=78 + ns=1;i=6006 + + + + AngleUnit + + i=68 + i=78 + ns=1;i=6006 + + + + B + Rotation around Y Axis (Pitch) as per ISO 9787:2013 + + i=63 + i=78 + ns=1;i=6006 + + + + C + Rotation around Z Axis (Yaw) as per ISO 9787:2013 + + i=63 + i=78 + ns=1;i=6006 + + + + Position + + i=18774 + i=78 + ns=1;i=6045 + ns=1;i=2004 + ns=1;i=6011 + ns=1;i=6012 + ns=1;i=6013 + + + + LengthUnit + + i=78 + i=68 + ns=1;i=6007 + + + + X + + i=63 + i=78 + ns=1;i=6007 + + + + Y + + i=63 + i=78 + ns=1;i=6007 + + + + Z + + i=63 + i=78 + ns=1;i=6007 + + + + SpatialObjectsListType + RSL Base + https://reference.opcfoundation.org/RSL/v100/docs/6.2.1 + + ns=1;i=5006 + i=2133 + ns=1;i=6024 + ns=1;i=6027 + i=58 + ns=1;i=6025 + + + + <SpatialObject> + + i=11508 + ns=1;i=1002 + ns=1;i=1003 + ns=1;i=6035 + ns=1;i=6038 + + + + DefaultInstanceBrowseName + The default BrowseName for instances of the type. + + i=78 + i=68 + ns=1;i=5006 + + + + 1 + SpatialObject + + + + + PositionFrame + + i=78 + ns=1;i=2003 + ns=1;i=6039 + ns=1;i=5006 + + + + Base + + i=63 + i=78 + ns=1;i=6038 + + + + Identifier + + i=78 + i=68 + ns=1;i=1003 + + + + NodeVersion + + i=78 + i=68 + ns=1;i=1003 + + + + WorldFrame + + i=78 + ns=1;i=2003 + ns=1;i=6026 + ns=1;i=1003 + + + + Base + WorldFrame as the origin of the coordinate system has no Base i.e. it must be null. + + i=63 + i=78 + ns=1;i=6025 + + + + SpatialObjectType + RSL Base + RSL SpatialObject Identifier + RSL SpatialObject AttachPoints + RSL SpatialObject InternalFrames + RSL SpatialObject AlternativeFrames + https://reference.opcfoundation.org/RSL/v100/docs/6.1.1 + + ns=1;i=5004 + ns=1;i=5002 + ns=1;i=6017 + ns=1;i=6016 + ns=1;i=5003 + ns=1;i=6014 + i=58 + + + + AlternativeFrames + + i=61 + i=80 + ns=1;i=6020 + ns=1;i=1002 + + + + <FrameIdentifier> + + i=11510 + ns=1;i=2003 + ns=1;i=5004 + ns=1;i=6021 + + + + Base + + i=63 + i=78 + ns=1;i=6020 + + + + AttachPoints + + i=61 + i=80 + ns=1;i=6018 + ns=1;i=1002 + + + + <FrameIdentifier> + + i=11510 + ns=1;i=2003 + ns=1;i=5002 + ns=1;i=6019 + + + + Base + + i=63 + i=78 + ns=1;i=6018 + + + + DefaultInstanceBrowseName + The default BrowseName for instances of the type. + + i=68 + ns=1;i=1002 + + + + 1 + SpatialObject + + + + + Identifier + + i=80 + i=68 + ns=1;i=1002 + + + + InternalFrames + + i=61 + i=80 + ns=1;i=6022 + ns=1;i=1002 + + + + <FrameIdentifier> + + i=11510 + ns=1;i=2003 + ns=1;i=5003 + ns=1;i=6023 + + + + Base + + i=63 + i=78 + ns=1;i=6022 + + + + PositionFrame + + i=78 + ns=1;i=2003 + ns=1;i=6015 + ns=1;i=1002 + + + + Base + + i=63 + i=78 + ns=1;i=6014 + + + + RelativeSpatialLocations + RSL Base + https://reference.opcfoundation.org/RSL/v100/docs/8.1 + + i=61 + i=31915 + + + + http://opcfoundation.org/UA/RSL/ + https://reference.opcfoundation.org/RSL/v100/docs/10.1 + + i=11616 + ns=1;i=6028 + i=11715 + ns=1;i=6029 + ns=1;i=6030 + ns=1;i=6031 + ns=1;i=6032 + ns=1;i=6033 + ns=1;i=6034 + + + + IsNamespaceSubset + + i=68 + ns=1;i=5005 + + + false + + + + NamespacePublicationDate + + i=68 + ns=1;i=5005 + + + 2023-01-12T00:00:00Z + + + + NamespaceUri + + i=68 + ns=1;i=5005 + + + http://opcfoundation.org/UA/RSL/ + + + + NamespaceVersion + + i=68 + ns=1;i=5005 + + + 1.00.1 + + + + StaticNodeIdTypes + + i=68 + ns=1;i=5005 + + + + StaticNumericNodeIdRange + + i=68 + ns=1;i=5005 + + + + StaticStringNodeIdPattern + + i=68 + ns=1;i=5005 + + + diff --git a/src/Opc.Ua.Positioning/NugetREADME.md b/src/Opc.Ua.Positioning/NugetREADME.md new file mode 100644 index 0000000000..6a92a13729 --- /dev/null +++ b/src/Opc.Ua.Positioning/NugetREADME.md @@ -0,0 +1,20 @@ +# OPC UA .NET Standard โ€” Positioning + +`OPCFoundation.NetStandard.Opc.Ua.Positioning` contains the source-generated +information models for OPC UA Relative Spatial Location (OPC 10000-210) and +Global Positioning (OPC 10000-211). + +The package exposes the generated NodeIds, DataTypes, NodeState classes, +ObjectType client proxies, encodeable registration, and `AddOpcUaRsl` / +`AddOpcUaGpos` model loaders. It also provides shared, NativeAOT-compatible +relative-frame and global-coordinate transformation primitives used by the +companion client and server packages. + +Pair it with `OPCFoundation.NetStandard.Opc.Ua.Positioning.Server` to expose +positioning information and with +`OPCFoundation.NetStandard.Opc.Ua.Positioning.Client` to consume it. + +The transformation API includes the OPC 10000-210 Annex B frame convention, +composition/inversion, WGS84 and ECEF/ENU conversion, and rigid, similarity, +or affine ground-control-point fitting with diagnostics and reflection +handling. diff --git a/src/Opc.Ua.Positioning/Opc.Ua.Positioning.csproj b/src/Opc.Ua.Positioning/Opc.Ua.Positioning.csproj new file mode 100644 index 0000000000..4a04225a2a --- /dev/null +++ b/src/Opc.Ua.Positioning/Opc.Ua.Positioning.csproj @@ -0,0 +1,60 @@ + + + $(AssemblyPrefix).Positioning + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.Positioning + Opc.Ua.Positioning + $(NoWarn);CS1591 + enable + OPC UA Relative Spatial Location (OPC 10000-210) and Global Positioning (OPC 10000-211) information models and transformation primitives. + true + NugetREADME.md + MIT + + true + true + + + + + + + + + $(PackageId).Debug + + + + + + + Analyzer + false + + + + + + + + + + + Opc.Ua.Rsl + + + + Opc.Ua.Gpos + + + + + + v105 + true + true + + + + + diff --git a/src/Opc.Ua.Positioning/Properties/AssemblyInfo.cs b/src/Opc.Ua.Positioning/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.Positioning/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.Positioning/Transforms/AngleMath.cs b/src/Opc.Ua.Positioning/Transforms/AngleMath.cs new file mode 100644 index 0000000000..daeba87e7d --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/AngleMath.cs @@ -0,0 +1,60 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Positioning +{ + /// + /// Angle unit conversion helpers. All internal trigonometry works in + /// radians; these helpers translate to and from the caller supplied + /// . + /// + internal static class AngleMath + { + private const double kDegreesToRadians = Math.PI / 180.0; + private const double kRadiansToDegrees = 180.0 / Math.PI; + + /// + /// Converts a value expressed in to radians. + /// + public static double ToRadians(double value, AngleUnit unit) + { + return unit == AngleUnit.Degrees ? value * kDegreesToRadians : value; + } + + /// + /// Converts a value expressed in radians to . + /// + public static double FromRadians(double radians, AngleUnit unit) + { + return unit == AngleUnit.Degrees ? radians * kRadiansToDegrees : radians; + } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/AngleUnit.cs b/src/Opc.Ua.Positioning/Transforms/AngleUnit.cs new file mode 100644 index 0000000000..4f96c54f29 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/AngleUnit.cs @@ -0,0 +1,50 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Positioning +{ + /// + /// Explicit unit of angular measure. The positioning APIs never guess the + /// unit of an angle: every method that consumes or produces angles requires + /// the caller to state whether the values are expressed in radians or in + /// degrees. + /// + public enum AngleUnit + { + /// + /// Angles are expressed in radians. + /// + Radians, + + /// + /// Angles are expressed in degrees. + /// + Degrees + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/GeographicCoordinates.cs b/src/Opc.Ua.Positioning/Transforms/GeographicCoordinates.cs new file mode 100644 index 0000000000..52a14e5fcd --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/GeographicCoordinates.cs @@ -0,0 +1,80 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Gpos; + +namespace Opc.Ua.Positioning +{ + /// + /// Helpers for reading and building + /// values, in particular the optional elevation field that is guarded by the + /// encoding mask + /// bit. + /// + internal static class GeographicCoordinates + { + /// + /// Returns true when the geographic coordinate carries an elevation. + /// + public static bool HasElevation(S3DGeographicCoordinateDataType coordinate) + { + return (coordinate.EncodingMask & (uint)S3DGeographicCoordinateDataTypeFields.Elevation) != 0; + } + + /// + /// Creates a geographic coordinate with longitude and latitude only (no + /// elevation set on the encoding mask). + /// + public static S3DGeographicCoordinateDataType Create(double longitude, double latitude) + { + return new() + { + EncodingMask = (uint)S3DGeographicCoordinateDataTypeFields.None, + Longitude = longitude, + Latitude = latitude, + Elevation = 0.0 + }; + } + + /// + /// Creates a geographic coordinate with longitude, latitude and + /// elevation (the elevation encoding mask bit is set). + /// + public static S3DGeographicCoordinateDataType Create(double longitude, double latitude, double elevation) + { + return new() + { + EncodingMask = (uint)S3DGeographicCoordinateDataTypeFields.Elevation, + Longitude = longitude, + Latitude = latitude, + Elevation = elevation + }; + } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitMode.cs b/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitMode.cs new file mode 100644 index 0000000000..32157e5761 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitMode.cs @@ -0,0 +1,56 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Positioning +{ + /// + /// The class of transform estimated when fitting ground control points. + /// + public enum GroundControlPointFitMode + { + /// + /// A rigid body transform: proper rotation and translation only, no + /// scaling. The determinant of the rotation is always +1. + /// + Rigid, + + /// + /// A similarity transform: proper rotation, uniform scale and + /// translation. The rotation is always a proper rotation (+1). + /// + Similarity, + + /// + /// A general affine transform: an arbitrary invertible linear map plus + /// translation. Reflections (negative determinant) are rejected unless + /// explicitly allowed by the fit options. + /// + Affine + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitOptions.cs b/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitOptions.cs new file mode 100644 index 0000000000..6ad3daf4f8 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitOptions.cs @@ -0,0 +1,66 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Positioning +{ + /// + /// Options controlling how ground control points are fitted. + /// + public sealed class GroundControlPointFitOptions + { + /// + /// The class of transform to estimate. Defaults to + /// . + /// + public GroundControlPointFitMode Mode { get; set; } = GroundControlPointFitMode.Rigid; + + /// + /// The unit of the angular fields (longitude and latitude) of the + /// geographic control points. Defaults to + /// which matches the EPSG:4326 convention used by + /// . + /// + public AngleUnit AngleUnit { get; set; } = AngleUnit.Degrees; + + /// + /// When true an fit with a + /// negative determinant (a reflection) is accepted. When false (the + /// default) an affine fit that would require a reflection is rejected. + /// Rigid and similarity fits always return a proper rotation regardless + /// of this flag. + /// + public bool AllowReflection { get; set; } + + /// + /// The coordinate reference system transformer used to map the geographic + /// control points to a metric frame. Defaults to WGS84 when null. + /// + public ICoordinateReferenceSystemTransformer? CoordinateReferenceSystem { get; set; } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitResult.cs b/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitResult.cs new file mode 100644 index 0000000000..e66c50bd61 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitResult.cs @@ -0,0 +1,202 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Gpos; + +namespace Opc.Ua.Positioning +{ + /// + /// The result of fitting ground control points: the estimated transform + /// together with quality diagnostics. The transform maps between a local + /// engineering frame and a geographic coordinate reference system through an + /// East-North-Up tangent plane fitted at the control point centroid. + /// + /// When is two the fit is a horizontal only fit; the + /// local Z component is ignored on the forward mapping and no elevation is + /// produced on the geographic output. The inverse mapping returns a local + /// point with a zero Z component. + /// + /// + public sealed class GroundControlPointFitResult + { + private readonly LocalTangentPlane m_tangentPlane; + private readonly double[,] m_forward; + private readonly double[,] m_inverse; + private readonly double[] m_translation; + + internal GroundControlPointFitResult( + GroundControlPointFitMode mode, + int dimension, + LocalTangentPlane tangentPlane, + double[,] forward, + double[,] inverse, + double[] translation, + double rootMeanSquareError, + double maxResidual, + double determinant, + int rank, + bool isInvertible) + { + Mode = mode; + Dimension = dimension; + m_tangentPlane = tangentPlane; + m_forward = forward; + m_inverse = inverse; + m_translation = translation; + RootMeanSquareError = rootMeanSquareError; + MaxResidual = maxResidual; + Determinant = determinant; + Rank = rank; + IsInvertible = isInvertible; + } + + /// + /// The class of transform that was estimated. + /// + public GroundControlPointFitMode Mode { get; } + + /// + /// The spatial dimension of the fit: three when a full three dimensional + /// fit was possible (all control points carried an elevation and the rank + /// permitted it), otherwise two for a horizontal only fit. + /// + public int Dimension { get; } + + /// + /// The root mean square of the residual distances between the fitted and + /// the observed geographic control points, in metres. + /// + public double RootMeanSquareError { get; } + + /// + /// The largest residual distance between a fitted and an observed + /// geographic control point, in metres. + /// + public double MaxResidual { get; } + + /// + /// The determinant of the linear part of the fitted transform. It is +1 + /// for a rigid fit and the scale raised to the dimension for a similarity + /// fit; for an affine fit it is the determinant of the estimated matrix. + /// + public double Determinant { get; } + + /// + /// The numerical rank of the control point configuration in the fit + /// dimension. + /// + public int Rank { get; } + + /// + /// True when the fitted transform is invertible (so + /// is well defined). + /// + public bool IsInvertible { get; } + + /// + /// Maps a point from the local engineering frame to a geographic + /// coordinate. + /// + /// + /// The local point to map. + /// + /// + /// The unit the returned longitude and latitude are expressed in. + /// + public S3DGeographicCoordinateDataType LocalToGlobal( + ThreeDCartesianCoordinates local, AngleUnit angleUnit) + { + local.ThrowIfNull(nameof(local)); + + Span source = [local.X, local.Y, local.Z]; + Span enu = stackalloc double[3]; + for (int k = 0; k < Dimension; k++) + { + double sum = m_translation[k]; + for (int j = 0; j < Dimension; j++) + { + sum += m_forward[k, j] * source[j]; + } + enu[k] = sum; + } + + var enuCoordinate = new ThreeDCartesianCoordinates + { + X = enu[0], + Y = enu[1], + Z = Dimension == 3 ? enu[2] : 0.0 + }; + + return m_tangentPlane.EnuToGeographic(enuCoordinate, angleUnit, Dimension == 3); + } + + /// + /// Maps a geographic coordinate to a point in the local engineering + /// frame. Requires to be true. + /// + /// + /// The geographic coordinate to map. + /// + /// + /// The unit the geographic longitude and latitude are expressed in. + /// + /// + public ThreeDCartesianCoordinates GlobalToLocal( + S3DGeographicCoordinateDataType global, AngleUnit angleUnit) + { + global.ThrowIfNull(nameof(global)); + if (!IsInvertible) + { + throw new InvalidOperationException( + "The fitted transform is not invertible; the global to local mapping is undefined."); + } + + ThreeDCartesianCoordinates enu = m_tangentPlane.GeographicToEnu(global, angleUnit); + Span target = [enu.X, enu.Y, enu.Z]; + Span local = stackalloc double[3]; + for (int k = 0; k < Dimension; k++) + { + double sum = 0.0; + for (int j = 0; j < Dimension; j++) + { + sum += m_inverse[k, j] * (target[j] - m_translation[j]); + } + local[k] = sum; + } + + return new ThreeDCartesianCoordinates + { + X = local[0], + Y = local[1], + Z = Dimension == 3 ? local[2] : 0.0 + }; + } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitter.cs b/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitter.cs new file mode 100644 index 0000000000..d24587f944 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/GroundControlPointFitter.cs @@ -0,0 +1,552 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Gpos; + +namespace Opc.Ua.Positioning +{ + /// + /// Fits a transform between a local engineering frame and a geographic + /// coordinate reference system from a set of ground control points. The fit + /// is carried out in a metric East-North-Up tangent plane placed at the + /// centroid of the control points so that the numerics stay well + /// conditioned. + /// + /// Three fit modes are supported. + /// (the default) and always + /// return a proper rotation (determinant +1) and correctly handle mirrored + /// inputs by returning the closest proper rotation rather than a reflection. + /// estimates a general linear + /// map and rejects reflections (negative determinant) unless + /// is set. + /// + /// A three dimensional fit is only attempted when every control point carries + /// an elevation and the configuration rank permits it; otherwise a horizontal + /// only two dimensional fit is performed and no elevation is fabricated. + /// + public sealed class GroundControlPointFitter + { + private readonly ICoordinateReferenceSystemTransformer m_crs; + + private const double kRankRelativeTolerance = 1e-9; + private const double kConditionLimit = 1e12; + + /// + /// Creates a fitter using the given coordinate reference system, or WGS84 + /// when none is supplied. + /// + /// + /// The coordinate reference system transformer to use; defaults to WGS84. + /// + public GroundControlPointFitter( + ICoordinateReferenceSystemTransformer? coordinateReferenceSystem = null) + { + m_crs = coordinateReferenceSystem ?? Wgs84CoordinateReferenceSystemTransformer.Instance; + } + + /// + /// Fits a transform from the supplied ground control points. + /// + /// + /// The ground control points that pair a local position with a geographic + /// position. + /// + /// + /// The fit options; when null the defaults (rigid fit, degrees) are used. + /// + /// + /// Thrown when the inputs are insufficient, duplicated, non-finite, rank + /// deficient, ill-conditioned or would produce a non-invertible or + /// reflected transform that the options do not allow. + /// + public GroundControlPointFitResult Fit( + ArrayOf controlPoints, + GroundControlPointFitOptions? options = null) + { + options ??= new GroundControlPointFitOptions(); + ICoordinateReferenceSystemTransformer crs = options.CoordinateReferenceSystem ?? m_crs; + AngleUnit unit = options.AngleUnit; + + if (controlPoints.IsNull || controlPoints.Count < 2) + { + throw new ArgumentException( + "At least two ground control points are required for a fit.", nameof(controlPoints)); + } + + int n = controlPoints.Count; + double[,] local = new double[n, 3]; + var geographic = new S3DGeographicCoordinateDataType[n]; + bool allElevations = true; + + for (int i = 0; i < n; i++) + { + GroundControlPointDataType gcp = controlPoints[i] + ?? throw new ArgumentException( + $"Ground control point at index {i} is null.", nameof(controlPoints)); + ThreeDCartesianCoordinates lp = gcp.LocalPosition + ?? throw new ArgumentException( + $"Ground control point at index {i} has no local position.", nameof(controlPoints)); + S3DGeographicCoordinateDataType gp = gcp.GlobalPosition + ?? throw new ArgumentException( + $"Ground control point at index {i} has no global position.", nameof(controlPoints)); + + if (!lp.X.IsFinite() || !lp.Y.IsFinite() || !lp.Z.IsFinite()) + { + throw new ArgumentException( + $"Ground control point at index {i} has a non-finite local position.", + nameof(controlPoints)); + } + if (!gp.Longitude.IsFinite() || !gp.Latitude.IsFinite()) + { + throw new ArgumentException( + $"Ground control point at index {i} has a non-finite geographic position.", + nameof(controlPoints)); + } + + bool hasElevation = GeographicCoordinates.HasElevation(gp); + if (hasElevation && !gp.Elevation.IsFinite()) + { + throw new ArgumentException( + $"Ground control point at index {i} has a non-finite elevation.", + nameof(controlPoints)); + } + + allElevations &= hasElevation; + local[i, 0] = lp.X; + local[i, 1] = lp.Y; + local[i, 2] = lp.Z; + geographic[i] = gp; + } + + // Anchor the tangent plane at the geographic centroid (mean ECEF). + double[] meanEcef = new double[3]; + for (int i = 0; i < n; i++) + { + ThreeDCartesianCoordinates e = crs.ToEarthCenteredEarthFixed(geographic[i], unit); + meanEcef[0] += e.X; + meanEcef[1] += e.Y; + meanEcef[2] += e.Z; + } + var centroidEcef = new ThreeDCartesianCoordinates + { + X = meanEcef[0] / n, + Y = meanEcef[1] / n, + Z = meanEcef[2] / n + }; + S3DGeographicCoordinateDataType origin = + crs.FromEarthCenteredEarthFixed(centroidEcef, unit, true); + var tangentPlane = new LocalTangentPlane(origin, unit, crs); + + double[,] enu = new double[n, 3]; + for (int i = 0; i < n; i++) + { + ThreeDCartesianCoordinates e = tangentPlane.GeographicToEnu(geographic[i], unit); + enu[i, 0] = e.X; + enu[i, 1] = e.Y; + enu[i, 2] = e.Z; + } + + RejectDuplicates(local, n, "local"); + RejectDuplicates(enu, n, "geographic"); + + int dimension = SelectDimension(options.Mode, allElevations, local, enu, n, out int rank); + + return FitInDimension(options, tangentPlane, local, enu, n, dimension, rank); + } + + private static int SelectDimension( + GroundControlPointFitMode mode, bool allElevations, double[,] local, double[,] enu, int n, out int rank) + { + if (allElevations) + { + int rank3 = Math.Min( + ConfigurationRank(local, n, 3), + ConfigurationRank(enu, n, 3)); + if (MeetsRequirement(mode, 3, n, rank3)) + { + rank = rank3; + return 3; + } + } + + int rank2 = Math.Min( + ConfigurationRank(local, n, 2), + ConfigurationRank(enu, n, 2)); + if (!MeetsRequirement(mode, 2, n, rank2)) + { + throw new ArgumentException( + "The ground control points are insufficient or rank deficient for the requested fit: " + + $"mode={mode}, points={n}, horizontal rank={rank2}."); + } + + rank = rank2; + return 2; + } + + private static bool MeetsRequirement(GroundControlPointFitMode mode, int dimension, int count, int rank) + { + if (mode == GroundControlPointFitMode.Affine) + { + // A general affine map needs full rank and one more point than the + // dimension. + return count >= dimension + 1 && rank >= dimension; + } + + // Rigid and similarity: the rotation is determined once the points + // span at least a hyperplane (dimension - 1) and there are enough of + // them. + int minRank = Math.Max(1, dimension - 1); + return count >= dimension && rank >= minRank; + } + + private GroundControlPointFitResult FitInDimension( + GroundControlPointFitOptions options, + LocalTangentPlane tangentPlane, + double[,] local, + double[,] enu, + int n, + int dimension, + int rank) + { + double[] sourceMean = Mean(local, n, dimension); + double[] targetMean = Mean(enu, n, dimension); + + double[,] forward; + double determinant; + + if (options.Mode == GroundControlPointFitMode.Affine) + { + forward = FitAffine(options, local, enu, n, dimension, sourceMean, targetMean, out determinant); + } + else + { + forward = FitRigidOrSimilarity( + options.Mode, local, enu, n, dimension, sourceMean, targetMean, out determinant); + } + + double[] translation = new double[dimension]; + for (int a = 0; a < dimension; a++) + { + double sum = targetMean[a]; + for (int b = 0; b < dimension; b++) + { + sum -= forward[a, b] * sourceMean[b]; + } + translation[a] = sum; + } + + bool isInvertible = SmallLinearAlgebra.TryInvert(forward, out double[,] inverse); + if (!isInvertible) + { + throw new ArgumentException( + "The fitted transform is not invertible (the control point configuration is degenerate)."); + } + + ComputeResiduals( + forward, translation, local, enu, n, dimension, + out double rms, out double maxResidual); + + return new GroundControlPointFitResult( + options.Mode, dimension, tangentPlane, forward, inverse, translation, + rms, maxResidual, determinant, rank, isInvertible); + } + + private static double[,] FitRigidOrSimilarity( + GroundControlPointFitMode mode, + double[,] local, + double[,] enu, + int n, + int dimension, + double[] sourceMean, + double[] targetMean, + out double determinant) + { + // Cross covariance target x source, normalised by the point count. + double[,] sigma = new double[dimension, dimension]; + double sourceVariance = 0.0; + for (int i = 0; i < n; i++) + { + for (int a = 0; a < dimension; a++) + { + double qa = enu[i, a] - targetMean[a]; + double pa = local[i, a] - sourceMean[a]; + sourceVariance += pa * pa; + for (int b = 0; b < dimension; b++) + { + sigma[a, b] += qa * (local[i, b] - sourceMean[b]); + } + } + } + for (int a = 0; a < dimension; a++) + { + for (int b = 0; b < dimension; b++) + { + sigma[a, b] /= n; + } + } + sourceVariance /= n; + + SmallLinearAlgebra.JacobiSvd(sigma, out double[,] u, out double[] s, out double[,] v); + + double detU = SmallLinearAlgebra.Determinant(u); + double detV = SmallLinearAlgebra.Determinant(v); + + // Reflection correction so the rotation stays proper (det +1). This is + // what makes a mirrored input resolve to the closest proper rotation + // instead of an improper one. + double[,] reflection = SmallLinearAlgebra.Identity(dimension); + reflection[dimension - 1, dimension - 1] = detU * detV < 0.0 ? -1.0 : 1.0; + + double[,] rotation = SmallLinearAlgebra.Multiply( + SmallLinearAlgebra.Multiply(u, reflection), + SmallLinearAlgebra.Transpose(v)); + + double scale = 1.0; + if (mode == GroundControlPointFitMode.Similarity) + { + double traceDs = 0.0; + for (int k = 0; k < dimension; k++) + { + traceDs += s[k] * reflection[k, k]; + } + scale = sourceVariance > 1e-300 ? traceDs / sourceVariance : 1.0; + if (!scale.IsFinite() || scale <= 0.0) + { + throw new ArgumentException( + "The similarity fit produced a non-positive scale; the control points are degenerate."); + } + } + + double[,] forward = new double[dimension, dimension]; + for (int a = 0; a < dimension; a++) + { + for (int b = 0; b < dimension; b++) + { + forward[a, b] = scale * rotation[a, b]; + } + } + + determinant = SmallLinearAlgebra.Determinant(forward); + return forward; + } + + private static double[,] FitAffine( + GroundControlPointFitOptions options, + double[,] local, + double[,] enu, + int n, + int dimension, + double[] sourceMean, + double[] targetMean, + out double determinant) + { + double[,] m = new double[dimension, dimension]; + double[,] cross = new double[dimension, dimension]; + for (int i = 0; i < n; i++) + { + for (int a = 0; a < dimension; a++) + { + double pa = local[i, a] - sourceMean[a]; + double qa = enu[i, a] - targetMean[a]; + for (int b = 0; b < dimension; b++) + { + double pb = local[i, b] - sourceMean[b]; + m[a, b] += pa * pb; + cross[a, b] += qa * pb; + } + } + } + + if (IsIllConditioned(m, dimension)) + { + throw new ArgumentException( + "The ground control points are ill-conditioned or rank deficient for an affine fit."); + } + + if (!SmallLinearAlgebra.TryInvert(m, out double[,] mInverse)) + { + throw new ArgumentException( + "The ground control points are rank deficient for an affine fit."); + } + + double[,] forward = SmallLinearAlgebra.Multiply(cross, mInverse); + determinant = SmallLinearAlgebra.Determinant(forward); + + if (Math.Abs(determinant) < 1e-12) + { + throw new ArgumentException( + "The affine fit is not invertible (near-zero determinant)."); + } + if (determinant < 0.0 && !options.AllowReflection) + { + throw new ArgumentException( + "The affine fit requires a reflection (negative determinant); " + + "set AllowReflection to accept it."); + } + + return forward; + } + + private static bool IsIllConditioned(double[,] m, int dimension) + { + SmallLinearAlgebra.JacobiSvd(m, out _, out double[] s, out _); + double max = s[0]; + double min = s[dimension - 1]; + if (max <= 1e-300) + { + return true; + } + if (min <= max * kRankRelativeTolerance) + { + return true; + } + return max / min > kConditionLimit; + } + + private static void ComputeResiduals( + double[,] forward, + double[] translation, + double[,] local, + double[,] enu, + int n, + int dimension, + out double rootMeanSquare, + out double maxResidual) + { + double sumSquares = 0.0; + maxResidual = 0.0; + for (int i = 0; i < n; i++) + { + double squared = 0.0; + for (int a = 0; a < dimension; a++) + { + double predicted = translation[a]; + for (int b = 0; b < dimension; b++) + { + predicted += forward[a, b] * local[i, b]; + } + double diff = predicted - enu[i, a]; + squared += diff * diff; + } + sumSquares += squared; + double distance = Math.Sqrt(squared); + if (distance > maxResidual) + { + maxResidual = distance; + } + } + rootMeanSquare = Math.Sqrt(sumSquares / n); + } + + private static double[] Mean(double[,] points, int n, int dimension) + { + double[] mean = new double[dimension]; + for (int i = 0; i < n; i++) + { + for (int a = 0; a < dimension; a++) + { + mean[a] += points[i, a]; + } + } + for (int a = 0; a < dimension; a++) + { + mean[a] /= n; + } + return mean; + } + + private static int ConfigurationRank(double[,] points, int n, int dimension) + { + double[] mean = Mean(points, n, dimension); + double[,] covariance = new double[dimension, dimension]; + for (int i = 0; i < n; i++) + { + for (int a = 0; a < dimension; a++) + { + double da = points[i, a] - mean[a]; + for (int b = 0; b < dimension; b++) + { + covariance[a, b] += da * (points[i, b] - mean[b]); + } + } + } + + SmallLinearAlgebra.JacobiSvd(covariance, out _, out double[] s, out _); + double max = s[0]; + if (max <= 1e-300) + { + return 0; + } + + int rank = 0; + for (int k = 0; k < dimension; k++) + { + if (s[k] > max * kRankRelativeTolerance) + { + rank++; + } + } + return rank; + } + + private static void RejectDuplicates(double[,] points, int n, string label) + { + double scale = 0.0; + for (int i = 0; i < n; i++) + { + double magnitude = Math.Sqrt( + (points[i, 0] * points[i, 0]) + + (points[i, 1] * points[i, 1]) + + (points[i, 2] * points[i, 2])); + if (magnitude > scale) + { + scale = magnitude; + } + } + double tolerance = 1e-9 * (1.0 + scale); + + for (int i = 0; i < n; i++) + { + for (int j = i + 1; j < n; j++) + { + double dx = points[i, 0] - points[j, 0]; + double dy = points[i, 1] - points[j, 1]; + double dz = points[i, 2] - points[j, 2]; + double distance = Math.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); + if (distance <= tolerance) + { + throw new ArgumentException( + $"Ground control points at indices {i} and {j} have duplicate {label} positions."); + } + } + } + } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/ICoordinateReferenceSystemTransformer.cs b/src/Opc.Ua.Positioning/Transforms/ICoordinateReferenceSystemTransformer.cs new file mode 100644 index 0000000000..078d4e96b3 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/ICoordinateReferenceSystemTransformer.cs @@ -0,0 +1,85 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Gpos; + +namespace Opc.Ua.Positioning +{ + /// + /// Extension point that converts between a geographic coordinate reference + /// system (a of longitude, + /// latitude and optional elevation) and an earth centered earth fixed (ECEF) + /// geocentric cartesian coordinate. + /// + /// The built in + /// implements the WGS84 / EPSG:4326 datum. Provide a custom implementation to + /// support a different datum or CRS and pass it to the positioning APIs. + /// + /// + public interface ICoordinateReferenceSystemTransformer + { + /// + /// A stable identifier of the coordinate reference system, for example + /// "EPSG:4326". + /// + string CoordinateReferenceSystem { get; } + + /// + /// Converts a geographic coordinate to an ECEF cartesian coordinate. + /// When the geographic coordinate carries no elevation it is treated as + /// lying on the reference ellipsoid (height zero); no elevation is + /// fabricated on the result. + /// + /// + /// The geographic coordinate to convert. + /// + /// + /// The unit the longitude and latitude are expressed in. + /// + ThreeDCartesianCoordinates ToEarthCenteredEarthFixed( + S3DGeographicCoordinateDataType geographic, AngleUnit angleUnit); + + /// + /// Converts an ECEF cartesian coordinate to a geographic coordinate. + /// + /// + /// The ECEF coordinate to convert. + /// + /// + /// The unit the returned longitude and latitude are expressed in. + /// + /// + /// When true the ellipsoidal height is written to the elevation field of + /// the result; when false the elevation field is omitted so no elevation + /// is fabricated for horizontal only workflows. + /// + S3DGeographicCoordinateDataType FromEarthCenteredEarthFixed( + ThreeDCartesianCoordinates earthCenteredEarthFixed, AngleUnit angleUnit, bool includeElevation); + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/LocalTangentPlane.cs b/src/Opc.Ua.Positioning/Transforms/LocalTangentPlane.cs new file mode 100644 index 0000000000..543a55a219 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/LocalTangentPlane.cs @@ -0,0 +1,168 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Gpos; + +namespace Opc.Ua.Positioning +{ + /// + /// A local East-North-Up (ENU) tangent plane anchored at a geographic + /// origin. It converts between the earth centered earth fixed (ECEF) frame + /// of a coordinate reference system and a local metric ENU frame whose X axis + /// points east, Y axis points north and Z axis points up along the ellipsoid + /// normal at the origin. + /// + public sealed class LocalTangentPlane + { + private readonly Vector3 m_originEcef; + private readonly Matrix3x3 m_ecefToEnu; + private readonly Matrix3x3 m_enuToEcef; + + /// + /// Creates a tangent plane anchored at the given geographic origin. + /// + /// + /// The geographic origin of the tangent plane. + /// + /// + /// The unit the origin longitude and latitude are expressed in. + /// + /// + /// The coordinate reference system to use; defaults to WGS84 when null. + /// + public LocalTangentPlane( + S3DGeographicCoordinateDataType origin, + AngleUnit angleUnit, + ICoordinateReferenceSystemTransformer? coordinateReferenceSystem = null) + { + origin.ThrowIfNull(nameof(origin)); + + CoordinateReferenceSystem = coordinateReferenceSystem ?? Wgs84CoordinateReferenceSystemTransformer.Instance; + Origin = origin; + + ThreeDCartesianCoordinates originEcef = CoordinateReferenceSystem.ToEarthCenteredEarthFixed(origin, angleUnit); + m_originEcef = new Vector3(originEcef.X, originEcef.Y, originEcef.Z); + + double lon = AngleMath.ToRadians(origin.Longitude, angleUnit); + double lat = AngleMath.ToRadians(origin.Latitude, angleUnit); + + double sinLon = Math.Sin(lon); + double cosLon = Math.Cos(lon); + double sinLat = Math.Sin(lat); + double cosLat = Math.Cos(lat); + + // Rotation from ECEF into ENU at the origin. + m_ecefToEnu = new Matrix3x3( + -sinLon, cosLon, 0.0, + -sinLat * cosLon, -sinLat * sinLon, cosLat, + cosLat * cosLon, cosLat * sinLon, sinLat); + + m_enuToEcef = m_ecefToEnu.Transpose(); + } + + /// + /// The geographic origin of the tangent plane. + /// + public S3DGeographicCoordinateDataType Origin { get; } + + /// + /// The coordinate reference system used by the tangent plane. + /// + public ICoordinateReferenceSystemTransformer CoordinateReferenceSystem { get; } + + /// + /// Converts an ECEF coordinate into the local ENU frame. + /// + /// + /// The ECEF coordinate to convert. + /// + public ThreeDCartesianCoordinates EarthCenteredEarthFixedToEnu( + ThreeDCartesianCoordinates earthCenteredEarthFixed) + { + earthCenteredEarthFixed.ThrowIfNull( + nameof(earthCenteredEarthFixed)); + var ecef = new Vector3( + earthCenteredEarthFixed.X, earthCenteredEarthFixed.Y, earthCenteredEarthFixed.Z); + Vector3 enu = m_ecefToEnu.Transform(ecef - m_originEcef); + return new ThreeDCartesianCoordinates { X = enu.X, Y = enu.Y, Z = enu.Z }; + } + + /// + /// Converts a local ENU coordinate back into the ECEF frame. + /// + /// + /// The local ENU coordinate to convert. + /// + public ThreeDCartesianCoordinates EnuToEarthCenteredEarthFixed(ThreeDCartesianCoordinates enu) + { + enu.ThrowIfNull(nameof(enu)); + var local = new Vector3(enu.X, enu.Y, enu.Z); + Vector3 ecef = m_enuToEcef.Transform(local) + m_originEcef; + return new ThreeDCartesianCoordinates { X = ecef.X, Y = ecef.Y, Z = ecef.Z }; + } + + /// + /// Converts a geographic coordinate directly into the local ENU frame. + /// + /// + /// The geographic coordinate to convert. + /// + /// + /// The unit the geographic longitude and latitude are expressed in. + /// + public ThreeDCartesianCoordinates GeographicToEnu( + S3DGeographicCoordinateDataType geographic, AngleUnit angleUnit) + { + geographic.ThrowIfNull(nameof(geographic)); + return EarthCenteredEarthFixedToEnu(CoordinateReferenceSystem.ToEarthCenteredEarthFixed(geographic, angleUnit)); + } + + /// + /// Converts a local ENU coordinate directly into a geographic coordinate. + /// + /// + /// The local ENU coordinate to convert. + /// + /// + /// The unit the returned geographic longitude and latitude are expressed in. + /// + /// + /// When true the ellipsoidal height is written to the elevation field of + /// the result. + /// + public S3DGeographicCoordinateDataType EnuToGeographic( + ThreeDCartesianCoordinates enu, AngleUnit angleUnit, bool includeElevation) + { + enu.ThrowIfNull(nameof(enu)); + return CoordinateReferenceSystem.FromEarthCenteredEarthFixed( + EnuToEarthCenteredEarthFixed(enu), angleUnit, includeElevation); + } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/Matrix3x3.cs b/src/Opc.Ua.Positioning/Transforms/Matrix3x3.cs new file mode 100644 index 0000000000..a550bf11f2 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/Matrix3x3.cs @@ -0,0 +1,206 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Positioning +{ + /// + /// Minimal immutable row-major 3x3 matrix of doubles used internally by the + /// positioning transforms. Column vectors are transformed as M * v. + /// + internal readonly struct Matrix3x3 : IEquatable + { + /// + /// Row 0, column 0. + /// + public double M00 { get; } + + /// + /// Row 0, column 1. + /// + public double M01 { get; } + + /// + /// Row 0, column 2. + /// + public double M02 { get; } + + /// + /// Row 1, column 0. + /// + public double M10 { get; } + + /// + /// Row 1, column 1. + /// + public double M11 { get; } + + /// + /// Row 1, column 2. + /// + public double M12 { get; } + + /// + /// Row 2, column 0. + /// + public double M20 { get; } + + /// + /// Row 2, column 1. + /// + public double M21 { get; } + + /// + /// Row 2, column 2. + /// + public double M22 { get; } + + /// + /// The identity matrix. + /// + public static Matrix3x3 Identity => new( + 1.0, 0.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0); + + /// + /// Creates a new matrix from its elements in row-major order. + /// + public Matrix3x3( + double m00, double m01, double m02, + double m10, double m11, double m12, + double m20, double m21, double m22) + { + M00 = m00; + M01 = m01; + M02 = m02; + M10 = m10; + M11 = m11; + M12 = m12; + M20 = m20; + M21 = m21; + M22 = m22; + } + + /// + /// Returns the determinant of the matrix. + /// + public double Determinant + => (M00 * ((M11 * M22) - (M12 * M21))) - + (M01 * ((M10 * M22) - (M12 * M20))) + + (M02 * ((M10 * M21) - (M11 * M20))); + + /// + /// Returns the transpose of the matrix. + /// + public Matrix3x3 Transpose() + { + return new( + M00, M10, M20, + M01, M11, M21, + M02, M12, M22); + } + + /// + /// Multiplies the matrix by a column vector. + /// + public Vector3 Transform(Vector3 v) + { + return new( + (M00 * v.X) + (M01 * v.Y) + (M02 * v.Z), + (M10 * v.X) + (M11 * v.Y) + (M12 * v.Z), + (M20 * v.X) + (M21 * v.Y) + (M22 * v.Z)); + } + + /// + /// Multiplies two matrices. + /// + public static Matrix3x3 operator *(Matrix3x3 a, Matrix3x3 b) + { + return new( + (a.M00 * b.M00) + (a.M01 * b.M10) + (a.M02 * b.M20), + (a.M00 * b.M01) + (a.M01 * b.M11) + (a.M02 * b.M21), + (a.M00 * b.M02) + (a.M01 * b.M12) + (a.M02 * b.M22), + (a.M10 * b.M00) + (a.M11 * b.M10) + (a.M12 * b.M20), + (a.M10 * b.M01) + (a.M11 * b.M11) + (a.M12 * b.M21), + (a.M10 * b.M02) + (a.M11 * b.M12) + (a.M12 * b.M22), + (a.M20 * b.M00) + (a.M21 * b.M10) + (a.M22 * b.M20), + (a.M20 * b.M01) + (a.M21 * b.M11) + (a.M22 * b.M21), + (a.M20 * b.M02) + (a.M21 * b.M12) + (a.M22 * b.M22)); + } + + /// + /// Scales every element of the matrix. + /// + public static Matrix3x3 operator *(Matrix3x3 a, double s) + { + return new( + a.M00 * s, a.M01 * s, a.M02 * s, + a.M10 * s, a.M11 * s, a.M12 * s, + a.M20 * s, a.M21 * s, a.M22 * s); + } + + /// + public bool Equals(Matrix3x3 other) + { + return M00.Equals(other.M00) && + M01.Equals(other.M01) && + M02.Equals(other.M02) && + M10.Equals(other.M10) && + M11.Equals(other.M11) && + M12.Equals(other.M12) && + M20.Equals(other.M20) && + M21.Equals(other.M21) && + M22.Equals(other.M22); + } + + /// + public override bool Equals(object? obj) + { + return obj is Matrix3x3 other && Equals(other); + } + + /// + public override int GetHashCode() + { + var hash = default(HashCode); + hash.Add(M00); + hash.Add(M01); + hash.Add(M02); + hash.Add(M10); + hash.Add(M11); + hash.Add(M12); + hash.Add(M20); + hash.Add(M21); + hash.Add(M22); + return hash.ToHashCode(); + } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/RslFrameTransform.cs b/src/Opc.Ua.Positioning/Transforms/RslFrameTransform.cs new file mode 100644 index 0000000000..d2182ef90d --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/RslFrameTransform.cs @@ -0,0 +1,311 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Positioning +{ + /// + /// An immutable rigid body transform expressed with the Relative Spatial + /// Location (RSL, OPC 10000-210) frame conventions. + /// + /// The coordinate system is right handed. The three orientation angles of a + /// follow the RSL Annex B definition: + /// A is the roll about the X axis, B is the pitch about the Y + /// axis and C is the yaw about the Z axis. Orientation is an intrinsic + /// z-y'-x'' rotation which, for column vectors, is the matrix product + /// Rz(C) * Ry(B) * Rx(A). A point is transformed as + /// p' = R * p + t. + /// + /// The type is a readonly value; every operation returns a new transform and + /// never mutates this. + /// + public readonly struct RslFrameTransform : IEquatable + { + private readonly Matrix3x3 m_rotation; + private readonly Vector3 m_translation; + private readonly bool m_initialized; + + internal RslFrameTransform(Matrix3x3 rotation, Vector3 translation) + { + m_rotation = rotation; + m_translation = translation; + m_initialized = true; + } + + /// + /// The identity transform (no rotation, no translation). + /// + public static RslFrameTransform Identity + => new(Matrix3x3.Identity, Vector3.Zero); + + /// + /// Builds a transform from an RSL . The frame + /// carries the translation in its + /// and the orientation angles in its . + /// + /// + /// The frame to convert. + /// + /// + /// The unit the orientation angles are expressed in. + /// + public static RslFrameTransform FromFrame(ThreeDFrame frame, AngleUnit angleUnit) + { + frame.ThrowIfNull(nameof(frame)); + return FromComponents(frame.Orientation, frame.CartesianCoordinates, angleUnit); + } + + /// + /// Builds a transform from an explicit orientation and translation. + /// + /// + /// The RSL orientation angles (roll A, pitch B, yaw C). + /// + /// + /// The translation of the frame origin. + /// + /// + /// The unit the orientation angles are expressed in. + /// + public static RslFrameTransform FromComponents( + ThreeDOrientation orientation, + ThreeDCartesianCoordinates translation, + AngleUnit angleUnit) + { + orientation.ThrowIfNull(nameof(orientation)); + translation.ThrowIfNull(nameof(translation)); + + double a = AngleMath.ToRadians(orientation.A, angleUnit); + double b = AngleMath.ToRadians(orientation.B, angleUnit); + double c = AngleMath.ToRadians(orientation.C, angleUnit); + + Matrix3x3 rotation = RotationFromEuler(a, b, c); + var t = new Vector3(translation.X, translation.Y, translation.Z); + return new RslFrameTransform(rotation, t); + } + + /// + /// Converts the transform back to an RSL . The + /// orientation angles are recovered from the rotation using the RSL + /// z-y'-x'' convention. At the gimbal lock singularity (pitch of plus or + /// minus ninety degrees) the roll is deterministically resolved to zero + /// and the yaw absorbs the coupled rotation. + /// + /// + /// The unit the returned orientation angles are expressed in. + /// + public ThreeDFrame ToFrame(AngleUnit angleUnit) + { + EulerFromRotation(Rotation, out double a, out double b, out double c); + return new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates + { + X = m_translation.X, + Y = m_translation.Y, + Z = m_translation.Z + }, + Orientation = new ThreeDOrientation + { + A = AngleMath.FromRadians(a, angleUnit), + B = AngleMath.FromRadians(b, angleUnit), + C = AngleMath.FromRadians(c, angleUnit) + } + }; + } + + /// + /// Transforms a point by applying the rotation and then the translation. + /// + /// + /// The point to transform. + /// + public ThreeDCartesianCoordinates TransformPoint(ThreeDCartesianCoordinates point) + { + point.ThrowIfNull(nameof(point)); + Vector3 p = Rotation.Transform(new Vector3(point.X, point.Y, point.Z)) + m_translation; + return new ThreeDCartesianCoordinates { X = p.X, Y = p.Y, Z = p.Z }; + } + + /// + /// Rotates a direction vector without applying the translation. + /// + /// + /// The direction to rotate. + /// + public ThreeDCartesianCoordinates TransformDirection(ThreeDCartesianCoordinates direction) + { + direction.ThrowIfNull(nameof(direction)); + Vector3 d = Rotation.Transform(new Vector3(direction.X, direction.Y, direction.Z)); + return new ThreeDCartesianCoordinates { X = d.X, Y = d.Y, Z = d.Z }; + } + + /// + /// Returns the composition this โˆ˜ other. The resulting transform + /// applies first and then this, i.e. + /// Compose(other).TransformPoint(p) == this.TransformPoint(other.TransformPoint(p)). + /// Composition does not commute. + /// + /// + /// The transform applied before this one. + /// + public RslFrameTransform Compose(in RslFrameTransform other) + { + Matrix3x3 rotation = Rotation * other.Rotation; + Vector3 translation = Rotation.Transform(other.m_translation) + m_translation; + return new RslFrameTransform(rotation, translation); + } + + /// + /// Returns the inverse transform such that + /// Inverse().Compose(this) is the identity. + /// + public RslFrameTransform Inverse() + { + Matrix3x3 inverseRotation = Rotation.Transpose(); + Vector3 inverseTranslation = inverseRotation.Transform(m_translation) * -1.0; + return new RslFrameTransform(inverseRotation, inverseTranslation); + } + + /// + /// Returns the composition of two transforms. See . + /// + public static RslFrameTransform operator *(RslFrameTransform left, RslFrameTransform right) + { + return left.Compose(right); + } + + /// + /// Builds the RSL rotation matrix Rz(C) * Ry(B) * Rx(A) for the + /// given angles in radians. + /// + internal static Matrix3x3 RotationFromEuler(double a, double b, double c) + { + double ca = Math.Cos(a); + double sa = Math.Sin(a); + double cb = Math.Cos(b); + double sb = Math.Sin(b); + double cc = Math.Cos(c); + double sc = Math.Sin(c); + + return new Matrix3x3( + cc * cb, (cc * sb * sa) - (sc * ca), (cc * sb * ca) + (sc * sa), + sc * cb, (sc * sb * sa) + (cc * ca), (sc * sb * ca) - (cc * sa), + -sb, cb * sa, cb * ca); + } + + /// + /// Recovers the RSL roll, pitch and yaw (radians) from a rotation matrix + /// following the z-y'-x'' convention with deterministic gimbal lock + /// handling. + /// + internal static void EulerFromRotation(Matrix3x3 r, out double a, out double b, out double c) + { + double cosPitch = Math.Sqrt((r.M00 * r.M00) + (r.M10 * r.M10)); + const double kGimbalThreshold = 1e-9; + + if (cosPitch > kGimbalThreshold) + { + b = Math.Atan2(-r.M20, cosPitch); + a = Math.Atan2(r.M21, r.M22); + c = Math.Atan2(r.M10, r.M00); + } + else + { + // Gimbal lock: pitch is +/- 90 degrees. Fix roll to zero and let + // yaw absorb the remaining rotation. + b = r.M20 < 0.0 ? Math.PI / 2.0 : -Math.PI / 2.0; + a = 0.0; + c = Math.Atan2(-r.M01, r.M11); + } + } + + /// + /// The rotation matrix as a flat row-major array of nine elements. The + /// order is M00 M01 M02 M10 M11 M12 M20 M21 M22. + /// + public ArrayOf RotationMatrix + { + get + { + Matrix3x3 r = Rotation; + return ArrayOf.Create( + [ + r.M00, r.M01, r.M02, + r.M10, r.M11, r.M12, + r.M20, r.M21, r.M22 + ]); + } + } + + /// + /// The translation of the transform. + /// + public ThreeDCartesianCoordinates Translation + => new() { X = m_translation.X, Y = m_translation.Y, Z = m_translation.Z }; + + private Matrix3x3 Rotation + => m_initialized ? m_rotation : Matrix3x3.Identity; + + /// + public bool Equals(RslFrameTransform other) + { + return Rotation.Equals(other.Rotation) && m_translation.Equals(other.m_translation); + } + + /// + public override bool Equals(object? obj) + { + return obj is RslFrameTransform other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(Rotation, m_translation); + } + + /// + /// Compares two transforms for equality. + /// + public static bool operator ==(RslFrameTransform left, RslFrameTransform right) + { + return left.Equals(right); + } + + /// + /// Compares two transforms for inequality. + /// + public static bool operator !=(RslFrameTransform left, RslFrameTransform right) + { + return !left.Equals(right); + } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/SmallLinearAlgebra.cs b/src/Opc.Ua.Positioning/Transforms/SmallLinearAlgebra.cs new file mode 100644 index 0000000000..37322ea386 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/SmallLinearAlgebra.cs @@ -0,0 +1,367 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Positioning +{ + /// + /// Small dense linear algebra routines used by the ground control point + /// fitting. Everything is implemented from scratch (no third party linear + /// algebra) and works on square matrices with dimension two or three. The + /// singular value decomposition uses a one sided Jacobi sweep which is + /// numerically stable for the tiny matrices used here and allocation light. + /// + internal static class SmallLinearAlgebra + { + /// + /// Returns the n x n identity matrix. + /// + public static double[,] Identity(int n) + { + double[,] m = new double[n, n]; + for (int i = 0; i < n; i++) + { + m[i, i] = 1.0; + } + return m; + } + + /// + /// Multiplies two n x n matrices. + /// + public static double[,] Multiply(double[,] a, double[,] b) + { + int n = a.GetLength(0); + double[,] r = new double[n, n]; + for (int i = 0; i < n; i++) + { + for (int j = 0; j < n; j++) + { + double sum = 0.0; + for (int k = 0; k < n; k++) + { + sum += a[i, k] * b[k, j]; + } + r[i, j] = sum; + } + } + return r; + } + + /// + /// Returns the transpose of an n x n matrix. + /// + public static double[,] Transpose(double[,] a) + { + int n = a.GetLength(0); + double[,] r = new double[n, n]; + for (int i = 0; i < n; i++) + { + for (int j = 0; j < n; j++) + { + r[j, i] = a[i, j]; + } + } + return r; + } + + /// + /// Computes the determinant of a two or three dimensional square matrix. + /// + public static double Determinant(double[,] a) + { + int n = a.GetLength(0); + if (n == 2) + { + return (a[0, 0] * a[1, 1]) - (a[0, 1] * a[1, 0]); + } + return (a[0, 0] * ((a[1, 1] * a[2, 2]) - (a[1, 2] * a[2, 1]))) - + (a[0, 1] * ((a[1, 0] * a[2, 2]) - (a[1, 2] * a[2, 0]))) + + (a[0, 2] * ((a[1, 0] * a[2, 1]) - (a[1, 1] * a[2, 0]))); + } + + /// + /// Attempts to invert an n x n matrix using Gauss-Jordan elimination + /// with partial pivoting. Returns false when the matrix is singular. + /// + public static bool TryInvert(double[,] a, out double[,] inverse) + { + int n = a.GetLength(0); + double[,] m = (double[,])a.Clone(); + inverse = Identity(n); + + for (int col = 0; col < n; col++) + { + int pivot = col; + double best = Math.Abs(m[col, col]); + for (int r = col + 1; r < n; r++) + { + double v = Math.Abs(m[r, col]); + if (v > best) + { + best = v; + pivot = r; + } + } + + if (best < 1e-300) + { + inverse = Identity(n); + return false; + } + + if (pivot != col) + { + SwapRows(m, col, pivot); + SwapRows(inverse, col, pivot); + } + + double diag = m[col, col]; + for (int j = 0; j < n; j++) + { + m[col, j] /= diag; + inverse[col, j] /= diag; + } + + for (int r = 0; r < n; r++) + { + if (r == col) + { + continue; + } + double factor = m[r, col]; + if (factor == 0.0) + { + continue; + } + for (int j = 0; j < n; j++) + { + m[r, j] -= factor * m[col, j]; + inverse[r, j] -= factor * inverse[col, j]; + } + } + } + + return true; + } + + /// + /// Computes the singular value decomposition A = U * diag(s) * V^T + /// of a square matrix using one sided Jacobi rotations. The singular + /// values are returned in descending order and both + /// and are orthonormal (proper or improper) bases. + /// Degenerate columns are completed so that the returned factors remain + /// orthonormal even for rank deficient input. + /// + public static void JacobiSvd(double[,] a, out double[,] u, out double[] s, out double[,] v) + { + int n = a.GetLength(0); + + // Work on a copy whose columns are rotated to become orthogonal. + double[,] work = (double[,])a.Clone(); + v = Identity(n); + + const int kMaxSweeps = 60; + const double kTolerance = 1e-16; + + for (int sweep = 0; sweep < kMaxSweeps; sweep++) + { + double offNorm = 0.0; + for (int p = 0; p < n - 1; p++) + { + for (int q = p + 1; q < n; q++) + { + double alpha = 0.0; + double beta = 0.0; + double gamma = 0.0; + for (int i = 0; i < n; i++) + { + double aip = work[i, p]; + double aiq = work[i, q]; + alpha += aip * aip; + beta += aiq * aiq; + gamma += aip * aiq; + } + + offNorm += Math.Abs(gamma); + double denom = Math.Sqrt(alpha * beta); + if (denom < 1e-300 || Math.Abs(gamma) <= kTolerance * denom) + { + continue; + } + + double zeta = (beta - alpha) / (2.0 * gamma); + double t = Math.Sign(zeta) / (Math.Abs(zeta) + Math.Sqrt(1.0 + (zeta * zeta))); + if (zeta == 0.0) + { + t = 1.0; + } + double c = 1.0 / Math.Sqrt(1.0 + (t * t)); + double sn = c * t; + + for (int i = 0; i < n; i++) + { + double aip = work[i, p]; + double aiq = work[i, q]; + work[i, p] = (c * aip) - (sn * aiq); + work[i, q] = (sn * aip) + (c * aiq); + + double vip = v[i, p]; + double viq = v[i, q]; + v[i, p] = (c * vip) - (sn * viq); + v[i, q] = (sn * vip) + (c * viq); + } + } + } + + if (offNorm < 1e-300) + { + break; + } + } + + // Singular values are the norms of the rotated columns; U is the + // normalised column. Collect then sort in descending order. + s = new double[n]; + u = new double[n, n]; + for (int j = 0; j < n; j++) + { + double norm = 0.0; + for (int i = 0; i < n; i++) + { + norm += work[i, j] * work[i, j]; + } + norm = Math.Sqrt(norm); + s[j] = norm; + if (norm > 1e-300) + { + for (int i = 0; i < n; i++) + { + u[i, j] = work[i, j] / norm; + } + } + } + + SortDescending(u, s, v); + CompleteOrthonormalColumns(u); + } + + private static void SortDescending(double[,] u, double[] s, double[,] v) + { + int n = s.Length; + for (int i = 0; i < n - 1; i++) + { + int max = i; + for (int j = i + 1; j < n; j++) + { + if (s[j] > s[max]) + { + max = j; + } + } + if (max != i) + { + (s[i], s[max]) = (s[max], s[i]); + SwapColumns(u, i, max); + SwapColumns(v, i, max); + } + } + } + + private static void CompleteOrthonormalColumns(double[,] u) + { + int n = u.GetLength(0); + for (int j = 0; j < n; j++) + { + double norm = 0.0; + for (int i = 0; i < n; i++) + { + norm += u[i, j] * u[i, j]; + } + if (norm > 1e-18) + { + continue; + } + + if (n == 3) + { + // Fill a zero column with the cross product of the other two. + int a = (j + 1) % 3; + int b = (j + 2) % 3; + double cx = (u[1, a] * u[2, b]) - (u[2, a] * u[1, b]); + double cy = (u[2, a] * u[0, b]) - (u[0, a] * u[2, b]); + double cz = (u[0, a] * u[1, b]) - (u[1, a] * u[0, b]); + double len = Math.Sqrt((cx * cx) + (cy * cy) + (cz * cz)); + if (len > 1e-300) + { + u[0, j] = cx / len; + u[1, j] = cy / len; + u[2, j] = cz / len; + continue; + } + } + else if (n == 2) + { + int a = (j + 1) % 2; + double px = -u[1, a]; + double py = u[0, a]; + double len = Math.Sqrt((px * px) + (py * py)); + if (len > 1e-300) + { + u[0, j] = px / len; + u[1, j] = py / len; + continue; + } + } + + // Fall back to a canonical axis if everything else is degenerate. + u[j, j] = 1.0; + } + } + + private static void SwapRows(double[,] m, int r0, int r1) + { + int n = m.GetLength(1); + for (int j = 0; j < n; j++) + { + (m[r0, j], m[r1, j]) = (m[r1, j], m[r0, j]); + } + } + + private static void SwapColumns(double[,] m, int c0, int c1) + { + int n = m.GetLength(0); + for (int i = 0; i < n; i++) + { + (m[i, c0], m[i, c1]) = (m[i, c1], m[i, c0]); + } + } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/Vector3.cs b/src/Opc.Ua.Positioning/Transforms/Vector3.cs new file mode 100644 index 0000000000..7026709aa1 --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/Vector3.cs @@ -0,0 +1,169 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Positioning +{ + /// + /// Minimal immutable double precision 3D vector used by the positioning + /// transforms. Kept internal so the public surface only exposes the OPC UA + /// core type. + /// + internal readonly struct Vector3 : IEquatable + { + /// + /// The X component. + /// + public double X { get; } + + /// + /// The Y component. + /// + public double Y { get; } + + /// + /// The Z component. + /// + public double Z { get; } + + /// + /// The zero vector. + /// + public static Vector3 Zero => new(0.0, 0.0, 0.0); + + /// + /// Creates a new vector from its components. + /// + public Vector3(double x, double y, double z) + { + X = x; + Y = y; + Z = z; + } + + /// + /// Returns the squared Euclidean length. + /// + public double LengthSquared => (X * X) + (Y * Y) + (Z * Z); + + /// + /// Returns the Euclidean length. + /// + public double Length => Math.Sqrt(LengthSquared); + + /// + /// Returns true when all components are finite. + /// + public bool IsFinite + => X.IsFinite() && Y.IsFinite() && Z.IsFinite(); + + /// + /// Adds two vectors. + /// + public static Vector3 operator +(Vector3 a, Vector3 b) + { + return new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); + } + + /// + /// Subtracts two vectors. + /// + public static Vector3 operator -(Vector3 a, Vector3 b) + { + return new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); + } + + /// + /// Scales a vector. + /// + public static Vector3 operator *(Vector3 a, double s) + { + return new(a.X * s, a.Y * s, a.Z * s); + } + + /// + /// Scales a vector. + /// + public static Vector3 operator *(double s, Vector3 a) + { + return a * s; + } + + /// + /// Returns the dot product of two vectors. + /// + public static double Dot(Vector3 a, Vector3 b) + { + return (a.X * b.X) + (a.Y * b.Y) + (a.Z * b.Z); + } + + /// + /// Returns the cross product of two vectors. + /// + public static Vector3 Cross(Vector3 a, Vector3 b) + { + return new( + (a.Y * b.Z) - (a.Z * b.Y), + (a.Z * b.X) - (a.X * b.Z), + (a.X * b.Y) - (a.Y * b.X)); + } + + /// + /// Returns the component at the given zero based index (0=X, 1=Y, 2=Z). + /// + /// + public double this[int index] + => index switch + { + 0 => X, + 1 => Y, + 2 => Z, + _ => throw new ArgumentOutOfRangeException(nameof(index)) + }; + + /// + public bool Equals(Vector3 other) + { + return X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); + } + + /// + public override bool Equals(object? obj) + { + return obj is Vector3 other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(X, Y, Z); + } + } +} diff --git a/src/Opc.Ua.Positioning/Transforms/Wgs84CoordinateReferenceSystemTransformer.cs b/src/Opc.Ua.Positioning/Transforms/Wgs84CoordinateReferenceSystemTransformer.cs new file mode 100644 index 0000000000..69da5344ca --- /dev/null +++ b/src/Opc.Ua.Positioning/Transforms/Wgs84CoordinateReferenceSystemTransformer.cs @@ -0,0 +1,144 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Gpos; + +namespace Opc.Ua.Positioning +{ + /// + /// The built in for the + /// WGS84 datum (EPSG:4326). Longitude and latitude are geodetic angles and + /// elevation is the ellipsoidal height in metres. The conversions use the + /// standard WGS84 ellipsoid parameters and have no external dependencies, so + /// the type is safe for Native AOT. + /// + public sealed class Wgs84CoordinateReferenceSystemTransformer : ICoordinateReferenceSystemTransformer + { + /// + /// WGS84 semi-major axis in metres. + /// + internal const double SemiMajorAxis = 6378137.0; + + /// + /// WGS84 inverse flattening. + /// + internal const double InverseFlattening = 298.257223563; + + private const double kFlattening = 1.0 / InverseFlattening; + private const double kFirstEccentricitySquared = kFlattening * (2.0 - kFlattening); + private const double kSemiMinorAxis = SemiMajorAxis * (1.0 - kFlattening); + + /// + /// A shared, thread safe instance of the WGS84 transformer. + /// + public static Wgs84CoordinateReferenceSystemTransformer Instance { get; } = new(); + + /// + public string CoordinateReferenceSystem => "EPSG:4326"; + + /// + public ThreeDCartesianCoordinates ToEarthCenteredEarthFixed( + S3DGeographicCoordinateDataType geographic, AngleUnit angleUnit) + { + geographic.ThrowIfNull(nameof(geographic)); + + double lon = AngleMath.ToRadians(geographic.Longitude, angleUnit); + double lat = AngleMath.ToRadians(geographic.Latitude, angleUnit); + double h = GeographicCoordinates.HasElevation(geographic) ? geographic.Elevation : 0.0; + + double sinLat = Math.Sin(lat); + double cosLat = Math.Cos(lat); + double sinLon = Math.Sin(lon); + double cosLon = Math.Cos(lon); + + double n = SemiMajorAxis / Math.Sqrt(1.0 - (kFirstEccentricitySquared * sinLat * sinLat)); + + double x = (n + h) * cosLat * cosLon; + double y = (n + h) * cosLat * sinLon; + double z = ((n * (1.0 - kFirstEccentricitySquared)) + h) * sinLat; + + return new ThreeDCartesianCoordinates { X = x, Y = y, Z = z }; + } + + /// + public S3DGeographicCoordinateDataType FromEarthCenteredEarthFixed( + ThreeDCartesianCoordinates earthCenteredEarthFixed, AngleUnit angleUnit, bool includeElevation) + { + earthCenteredEarthFixed.ThrowIfNull( + nameof(earthCenteredEarthFixed)); + + double x = earthCenteredEarthFixed.X; + double y = earthCenteredEarthFixed.Y; + double z = earthCenteredEarthFixed.Z; + + double lon = Math.Atan2(y, x); + double p = Math.Sqrt((x * x) + (y * y)); + + double lat; + double height; + + if (p < 1e-9) + { + // On the polar axis latitude is +/- 90 degrees. + lat = z >= 0.0 ? Math.PI / 2.0 : -Math.PI / 2.0; + height = Math.Abs(z) - kSemiMinorAxis; + lon = 0.0; + } + else + { + // Iterative solution converging in a few steps for terrestrial + // heights. + lat = Math.Atan2(z, p * (1.0 - kFirstEccentricitySquared)); + double n = SemiMajorAxis; + for (int i = 0; i < 10; i++) + { + double sinLat = Math.Sin(lat); + n = SemiMajorAxis / Math.Sqrt(1.0 - (kFirstEccentricitySquared * sinLat * sinLat)); + double h = (p / Math.Cos(lat)) - n; + double newLat = Math.Atan2(z, p * (1.0 - (kFirstEccentricitySquared * n / (n + h)))); + if (Math.Abs(newLat - lat) < 1e-14) + { + lat = newLat; + break; + } + lat = newLat; + } + height = (p / Math.Cos(lat)) - n; + } + + double longitude = AngleMath.FromRadians(lon, angleUnit); + double latitude = AngleMath.FromRadians(lat, angleUnit); + + return includeElevation + ? GeographicCoordinates.Create(longitude, latitude, height) + : GeographicCoordinates.Create(longitude, latitude); + } + } +} diff --git a/src/Opc.Ua.ISA95.Server/Providers/Isa95GeoSpatialLocationServiceCollectionExtensions.cs b/src/Opc.Ua.Robotics.Client/Hosting/OpcUaRoboticsClientBuilderExtensions.cs similarity index 50% rename from src/Opc.Ua.ISA95.Server/Providers/Isa95GeoSpatialLocationServiceCollectionExtensions.cs rename to src/Opc.Ua.Robotics.Client/Hosting/OpcUaRoboticsClientBuilderExtensions.cs index ff06baf4a1..102b71dc4e 100644 --- a/src/Opc.Ua.ISA95.Server/Providers/Isa95GeoSpatialLocationServiceCollectionExtensions.cs +++ b/src/Opc.Ua.Robotics.Client/Hosting/OpcUaRoboticsClientBuilderExtensions.cs @@ -28,51 +28,54 @@ * ======================================================================*/ using System; -using Microsoft.Extensions.DependencyInjection; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection.Extensions; -using Opc.Ua.ISA95.Server.Providers; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Robotics.Client; -namespace Opc.Ua.ISA95.Server +namespace Microsoft.Extensions.DependencyInjection { /// - /// Dependency injection registration helpers for the OPC-10030 geospatial - /// location provider. Registration is optional; the - /// constructor is public and - /// can be used directly as a fallback. + /// Registers the Robotics client over the managed OPC UA session. /// - public static class Isa95GeoSpatialLocationServiceCollectionExtensions + public static class OpcUaRoboticsClientBuilderExtensions { /// - /// Registers the in-memory - /// as the singleton . + /// Registers a factory. The Robotics + /// client extends the Device Integration client, so the DI client + /// services are registered as well. /// - /// - /// The service collection to register with. + /// + /// The client builder returned by AddClient. /// - /// - /// An optional callback used to seed the provider. - /// - /// - /// The same service collection to allow chaining. - /// - /// is null. - public static IServiceCollection AddIsa95GeoSpatialLocationProvider( - this IServiceCollection services, - Action? configure = null) + public static IOpcUaClientBuilder AddRoboticsClient( + this IOpcUaClientBuilder builder) { - if (services == null) + builder.ThrowIfNull(nameof(builder)); + + builder.AddOpcUaDi(); + builder.Services.TryAddSingleton(sp => { - throw new ArgumentNullException(nameof(services)); - } + Func> sessionFactory = + sp.GetService>>() ?? + throw new InvalidOperationException( + "AddRoboticsClient requires AddClient to be called first."); + ITelemetryContext telemetry = + sp.GetRequiredService(); + return new RoboticsClientFactory(sessionFactory, telemetry); + }); - services.TryAddSingleton(_ => + builder.Services.TryAddSingleton< + Func>>(sp => { - var provider = new Isa95GeoSpatialLocationProvider(); - configure?.Invoke(provider); - return provider; + RoboticsClientFactory factory = + sp.GetRequiredService(); + return factory.CreateAsync; }); - return services; + return builder; } } } diff --git a/src/Opc.Ua.Robotics.Client/MotionDeviceSystemEntry.cs b/src/Opc.Ua.Robotics.Client/MotionDeviceSystemEntry.cs new file mode 100644 index 0000000000..c71f158739 --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/MotionDeviceSystemEntry.cs @@ -0,0 +1,52 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics.Client +{ + /// + /// A discovered MotionDeviceSystem instance. + /// + /// + /// The system NodeId. + /// + /// + /// The system BrowseName. + /// + /// + /// The system DisplayName. + /// + /// + /// The resolved type definition NodeId. + /// + public sealed record MotionDeviceSystemEntry( + NodeId NodeId, + QualifiedName BrowseName, + LocalizedText DisplayName, + NodeId TypeDefinitionId); +} diff --git a/src/Opc.Ua.Robotics.Client/NugetREADME.md b/src/Opc.Ua.Robotics.Client/NugetREADME.md new file mode 100644 index 0000000000..19db9d80b3 --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/NugetREADME.md @@ -0,0 +1,26 @@ +# Opc.Ua.Robotics.Client + +Client-side helpers for the **OPC 40010 Robotics** companion model. + +Built on **Opc.Ua.Robotics** and **Opc.Ua.Di.Client** โ€” Robotics types derive +from OPC 40001-1 IA, which derives from OPC 10000-100 DI, so `RoboticsClient` +composes `DiTopologyClient` instead of reimplementing device navigation. It lets +a generic OPC UA client (for example the OpenUSD connector or a viewer) work +with robot cells: + +- `DiscoverMotionDeviceSystemsAsync` / `DiscoverMotionDevicesAsync` / + `DiscoverControllersAsync` / `DiscoverAxesAsync` โ€” continuation-safe, + subtype-aware discovery of Robotics instances below a root node (defaulting to + the DI `DeviceSet`); +- `GetRoboticsTypeNameAsync` โ€” classify a discovered node against the server's + type hierarchy, so vendor specialisations resolve to their closest standard + Robotics type (`MotionDeviceSystem` / `MotionDevice` / `Axis` / `Controller`); +- `TryGetRoboticsTypeName` โ€” the offline exact-match variant, with no server + round-trip; +- `AddRoboticsClient()` โ€” fluent registration on `IOpcUaClientBuilder`, which + also registers the DI client services. + +Pair it with **Opc.Ua.OpenUsd.Client** to render and live-update the cell. + +See the [Robotics developer guide](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/Robotics.md) +and the [OpenUSD binding guide](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/OpenUsd.md). diff --git a/src/Opc.Ua.Robotics.Client/Opc.Ua.Robotics.Client.csproj b/src/Opc.Ua.Robotics.Client/Opc.Ua.Robotics.Client.csproj new file mode 100644 index 0000000000..6bc3c6704d --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/Opc.Ua.Robotics.Client.csproj @@ -0,0 +1,27 @@ + + + $(AssemblyPrefix).Robotics.Client + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.Robotics.Client + Opc.Ua.Robotics.Client + $(NoWarn);CS1591 + enable + Client-side helpers for the OPC 40010 Robotics companion model: discover MotionDeviceSystem instances over a session and identify Robotics types (MotionDeviceSystem / MotionDevice / Axis / Controller) from a node's TypeDefinition, so a generic OpenUSD connector or viewer can label and drive a robot-cell twin. + true + NugetREADME.md + MIT + true + true + + + $(PackageId).Debug + + + + + + + + + + diff --git a/src/Opc.Ua.Robotics.Client/Operations/RoboticsOperationsClient.cs b/src/Opc.Ua.Robotics.Client/Operations/RoboticsOperationsClient.cs new file mode 100644 index 0000000000..ab3ad7a436 --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/Operations/RoboticsOperationsClient.cs @@ -0,0 +1,398 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; +using Opc.Ua.Robotics.Operations; + +namespace Opc.Ua.Robotics.Client.Operations +{ + /// + /// Invokes opt-in, explicitly non-normative Robotics operation convention methods. + /// + public sealed class RoboticsOperationsClient + { + /// + /// Creates a convention operation client for one operations object. + /// + public RoboticsOperationsClient(ISession session, NodeId operationsNodeId, ITelemetryContext telemetry) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + OperationsNodeId = operationsNodeId.IsNull + ? throw new ArgumentException("An operations NodeId is required.", nameof(operationsNodeId)) + : operationsNodeId; + Telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + } + + /// + /// Gets the connected session. + /// + public ISession Session { get; } + + /// + /// Gets the operations object NodeId. + /// + public NodeId OperationsNodeId { get; } + + /// + /// Gets the telemetry context. + /// + public ITelemetryContext Telemetry { get; } + + /// + /// Invokes MoveTo by BrowseName. + /// + public Task MoveToAsync( + MoveToRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("MoveTo", ToArguments(request), cancellationToken); + } + + /// + /// Invokes MoveJ by BrowseName. + /// + public Task MoveJAsync( + JointMoveRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("MoveJ", ToArguments(request), cancellationToken); + } + + /// + /// Invokes MoveL by BrowseName. + /// + public Task MoveLAsync( + LinearMoveRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("MoveL", ToArguments(request), cancellationToken); + } + + /// + /// Invokes Grasp by BrowseName. + /// + public Task GraspAsync( + GraspRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("Grasp", ToArguments(request), cancellationToken); + } + + /// + /// Invokes Release by BrowseName. + /// + public Task ReleaseAsync( + ReleaseRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("Release", ToArguments(request), cancellationToken); + } + + /// + /// Invokes PickFrom by BrowseName. + /// + public Task PickFromAsync( + PickPlaceRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("PickFrom", ToArguments(request), cancellationToken); + } + + /// + /// Invokes PlaceAt by BrowseName. + /// + public Task PlaceAtAsync( + PickPlaceRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("PlaceAt", ToArguments(request), cancellationToken); + } + + /// + /// Invokes SwapTool by BrowseName. + /// + public Task SwapToolAsync( + ToolChangeRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("SwapTool", ToArguments(request), cancellationToken); + } + + /// + /// Invokes SetOutput by BrowseName. + /// + public Task SetOutputAsync( + OutputRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("SetOutput", ToArguments(request), cancellationToken); + } + + /// + /// Invokes CallProgram by BrowseName. + /// + public Task CallProgramAsync( + ProgramCallRequest request, + CancellationToken cancellationToken = default) + { + return InvokeStandardAsync("CallProgram", ToArguments(request), cancellationToken); + } + + /// + /// Invokes an application-specific operation by BrowseName. + /// + /// + /// The request CLR type. + /// + /// + /// The response CLR type. + /// + public async Task InvokeAsync( + string name, + TRequest request, + CancellationToken cancellationToken = default) + { + ArrayOf output = await CallAsync( + name, + [new Variant(ToGenericArguments(request))], + cancellationToken).ConfigureAwait(false); + + RoboticsOperationResult result = FromOutput(output); + if (typeof(TResponse) == typeof(RoboticsOperationResult)) + { + return Unsafe.As(ref result); + } + if (typeof(TResponse) == typeof(ArrayOf)) + { + ArrayOf values = result.Outputs ?? []; + return Unsafe.As, TResponse>(ref values); + } + if (typeof(TResponse) == typeof(Variant)) + { + ArrayOf values = result.Outputs ?? []; + Variant value = values.Count == 0 ? Variant.Null : values[0]; + return Unsafe.As(ref value); + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private async Task InvokeStandardAsync( + string name, + ArrayOf inputArguments, + CancellationToken cancellationToken) + { + ArrayOf output = await CallAsync(name, inputArguments, cancellationToken) + .ConfigureAwait(false); + return FromOutput(output); + } + + private async Task> CallAsync( + string name, + ArrayOf inputArguments, + CancellationToken cancellationToken) + { + NodeId methodId = await ResolveMethodAsync(name, cancellationToken).ConfigureAwait(false); + Variant[] arguments = new Variant[inputArguments.Count]; + for (int ii = 0; ii < inputArguments.Count; ii++) + { + arguments[ii] = inputArguments[ii]; + } + return await Session.CallAsync( + OperationsNodeId, + methodId, + cancellationToken, + arguments).ConfigureAwait(false); + } + + private async Task ResolveMethodAsync(string name, CancellationToken cancellationToken) + { + (_, _, ArrayOf references) = await Session.BrowseAsync( + requestHeader: null, + view: null, + nodeToBrowse: OperationsNodeId, + maxResultsToReturn: 0, + browseDirection: BrowseDirection.Forward, + referenceTypeId: Opc.Ua.Types.ReferenceTypeIds.HasComponent, + includeSubtypes: true, + nodeClassMask: (uint)NodeClass.Method, + ct: cancellationToken).ConfigureAwait(false); + + for (int ii = 0; ii < references.Count; ii++) + { + ReferenceDescription reference = references[ii]; + if (string.Equals(reference.BrowseName.Name, name, StringComparison.Ordinal)) + { + return ExpandedNodeId.ToNodeId(reference.NodeId, Session.NamespaceUris); + } + } + throw ServiceResultException.Create( + StatusCodes.BadNodeIdUnknown, + "Operation method '{0}' was not found below '{1}'.", + name, + OperationsNodeId); + } + + private static RoboticsOperationResult FromOutput(ArrayOf output) + { + if (output.Count == 0 || !output[0].TryGetValue(out StatusCode statusCode)) + { + return new RoboticsOperationResult(new ServiceResult(StatusCodes.BadUnexpectedError)); + } + string? message = null; + if (output.Count > 1 && !output[1].IsNull && output[1].TryGetValue(out string text)) + { + message = text; + } + ArrayOf? values = null; + if (output.Count > 2 && !output[2].IsNull && output[2].TryGetValue(out ArrayOf outputs)) + { + values = outputs; + } + return new RoboticsOperationResult(new ServiceResult(statusCode), message, values); + } + + private static ArrayOf ToArguments(MoveToRequest request) + { + return [ + Structure(request.TargetFrame), + Optional(request.SpeedFraction), + Optional(request.BlendRadius), + OptionalStructure(request.BlendRadiusUnits) + ]; + } + + private static ArrayOf ToArguments(JointMoveRequest request) + { + return [ + new Variant(request.JointTargets), + Structure(request.JointUnits), + Optional(request.SpeedFraction) + ]; + } + + private static ArrayOf ToArguments(LinearMoveRequest request) + { + return [ + Structure(request.TargetFrame), + new Variant(request.LinearSpeed), + Structure(request.LinearSpeedUnits), + Optional(request.Acceleration), + OptionalStructure(request.AccelerationUnits) + ]; + } + + private static ArrayOf ToArguments(GraspRequest request) + { + return [ + Optional(request.ForceNewtons), + Optional(request.Width), + OptionalStructure(request.WidthUnits), + new Variant((int)request.Approach) + ]; + } + + private static ArrayOf ToArguments(ReleaseRequest request) + { + return [ + new Variant((int)request.Mode), + OptionalStructure(request.TargetFrame) + ]; + } + + private static ArrayOf ToArguments(PickPlaceRequest request) + { + return [ + new Variant(request.StationOrLocationIdentifier), + new Variant(request.ObjectClass), + request.Attributes.Count == 0 ? Variant.Null : new Variant(ToExtensionObjects(request.Attributes)), + Optional(request.ForceNewtons) + ]; + } + + private static ArrayOf ToArguments(ToolChangeRequest request) + { + return [ + new Variant(request.ToolIdentifier), + request.DockStation == null ? Variant.Null : new Variant(request.DockStation) + ]; + } + + private static ArrayOf ToArguments(OutputRequest request) + { + return [new Variant(request.OutputLineIdentifier), request.Value]; + } + + private static ArrayOf ToArguments(ProgramCallRequest request) + { + return [new Variant(request.ProgramName), new Variant(request.Arguments)]; + } + + private static ArrayOf ToGenericArguments(TRequest request) + { + if (request is ArrayOf arguments) + { + return arguments; + } + if (request is Variant variant) + { + return [variant]; + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private static Variant Structure(IEncodeable value) + { + return new Variant(new ExtensionObject(value)); + } + + private static Variant Optional(double? value) + { + return value.HasValue ? new Variant(value.Value) : Variant.Null; + } + + private static Variant OptionalStructure(IEncodeable? value) + { + return value == null ? Variant.Null : Structure(value); + } + + private static ArrayOf ToExtensionObjects(ArrayOf values) + { + var result = new ExtensionObject[values.Count]; + for (int ii = 0; ii < values.Count; ii++) + { + result[ii] = new ExtensionObject(values[ii]); + } + return result.ToArrayOf(); + } + } +} diff --git a/src/Opc.Ua.Robotics.Client/Properties/AssemblyInfo.cs b/src/Opc.Ua.Robotics.Client/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.Robotics.Client/RoboticsClient.Accessors.cs b/src/Opc.Ua.Robotics.Client/RoboticsClient.Accessors.cs new file mode 100644 index 0000000000..0c35354abd --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/RoboticsClient.Accessors.cs @@ -0,0 +1,108 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; +using Opc.Ua.Client.FileSystem; +using Opc.Ua.Client.Subscriptions.Streaming; +using Opc.Ua.Robotics.Client.Operations; + +namespace Opc.Ua.Robotics.Client +{ + public sealed partial class RoboticsClient + { + /// + /// Opens the standard SystemOperation client for a controller. + /// + public SystemOperationClient SystemOperation(NodeId controller) + { + return new SystemOperationClient(Session, controller, Telemetry, resolveFromController: true); + } + + /// + /// Opens the standard TaskControl client for a task control. + /// + public TaskControlClient TaskControl(NodeId taskControl) + { + return new TaskControlClient(Session, taskControl, Telemetry); + } + + /// + /// Opens the controller Programs directory as a FileSystemClient. + /// + public async Task ProgramsAsync( + NodeId controller, + CancellationToken cancellationToken = default) + { + ControllerTypeClient proxy = new(Session, controller, Telemetry); + FileDirectoryTypeClient? programs = await proxy.GetProgramsAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + if (programs == null) + { + throw new ServiceResultException( + StatusCodes.BadNotFound, + $"Controller '{controller}' does not expose Programs."); + } + return new FileSystemClient(Session, programs.ObjectId); + } + + /// + /// Opens the non-normative operation convention client for a motion device. + /// + /// + /// The motion device carrying the application-owned operations object. + /// + /// + /// Cancels the operation. + /// + public async Task OperationsAsync( + NodeId motionDevice, + CancellationToken cancellationToken = default) + { + NodeId operationsId = await ResolveChildAsync( + motionDevice, + "Operations", + cancellationToken).ConfigureAwait(false); + return new RoboticsOperationsClient(Session, operationsId, Telemetry); + } + + internal static IStreamingSubscription GetDefaultStreaming(ISession session) + { + if (session is ManagedSession managedSession) + { + return managedSession.DefaultStreaming; + } + throw new ServiceResultException( + StatusCodes.BadInvalidState, + "Observation without an explicit IStreamingSubscription requires ManagedSession."); + } + } +} diff --git a/src/Opc.Ua.Robotics.Client/RoboticsClient.Topology.cs b/src/Opc.Ua.Robotics.Client/RoboticsClient.Topology.cs new file mode 100644 index 0000000000..89d8cfd74b --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/RoboticsClient.Topology.cs @@ -0,0 +1,928 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; +using Opc.Ua.Client.Subscriptions; +using Opc.Ua.Client.Subscriptions.Streaming; +using DiBrowseNames = Opc.Ua.Di.BrowseNames; +using RoboticsBrowseNames = Opc.Ua.Robotics.BrowseNames; + +namespace Opc.Ua.Robotics.Client +{ + public sealed partial class RoboticsClient + { + /// + /// Enumerates MotionDeviceSystem entries below the DI DeviceSet. + /// + public async IAsyncEnumerable EnumerateMotionDeviceSystemsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + int ns = Session.NamespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics); + if (ns < 0) + { + yield break; + } + var wantedType = new NodeId(RoboticsModel.MotionDeviceSystemType, (ushort)ns); + ArrayOf references = await BrowseObjectsAsync( + Topology.DeviceSetId, + Opc.Ua.ReferenceTypeIds.HierarchicalReferences, + cancellationToken).ConfigureAwait(false); + for (int ii = 0; ii < references.Count; ii++) + { + ReferenceDescription reference = references[ii]; + NodeId typeDefinition = ExpandedNodeId.ToNodeId(reference.TypeDefinition, Session.NamespaceUris); + NodeId nodeId = ExpandedNodeId.ToNodeId(reference.NodeId, Session.NamespaceUris); + if (!nodeId.IsNull && !typeDefinition.IsNull && + await Session.NodeCache.IsTypeOfAsync(typeDefinition, wantedType, cancellationToken) + .ConfigureAwait(false)) + { + yield return new MotionDeviceSystemEntry( + nodeId, + reference.BrowseName, + reference.DisplayName, + typeDefinition); + } + } + } + + /// + /// Reads a focused topology snapshot rooted at one MotionDeviceSystem. + /// + public async Task ReadSystemAsync( + NodeId system, + CancellationToken cancellationToken = default) + { + MotionDeviceSystemSnapshot systemSnapshot = await ReadSystemNodeAsync(system, cancellationToken) + .ConfigureAwait(false); + var controllers = new List(); + var motionDevices = new List(); + var axes = new List(); + var loads = new List(); + var powerTrains = new List(); + var motors = new List(); + var gears = new List(); + var drives = new List(); + var safetyStates = new List(); + var taskControls = new List(); + var taskModules = new List(); + + for (int ii = 0; ii < systemSnapshot.ControllerIds.Count; ii++) + { + ControllerSnapshot controller = await ReadControllerAsync( + systemSnapshot.ControllerIds[ii], cancellationToken).ConfigureAwait(false); + controllers.Add(controller); + for (int jj = 0; jj < controller.TaskControlIds.Count; jj++) + { + TaskControlSnapshot taskControl = await ReadTaskControlAsync( + controller.TaskControlIds[jj], cancellationToken).ConfigureAwait(false); + taskControls.Add(taskControl); + for (int kk = 0; kk < taskControl.TaskModuleIds.Count; kk++) + { + taskModules.Add(await ReadTaskModuleAsync( + taskControl.TaskModuleIds[kk], cancellationToken).ConfigureAwait(false)); + } + } + } + for (int ii = 0; ii < systemSnapshot.MotionDeviceIds.Count; ii++) + { + MotionDeviceSnapshot motionDevice = await ReadMotionDeviceAsync( + systemSnapshot.MotionDeviceIds[ii], cancellationToken).ConfigureAwait(false); + motionDevices.Add(motionDevice); + if (!motionDevice.FlangeLoadId.IsNull) + { + loads.Add(await ReadLoadAsync(motionDevice.FlangeLoadId, cancellationToken).ConfigureAwait(false)); + } + for (int jj = 0; jj < motionDevice.AxisIds.Count; jj++) + { + AxisSnapshot axis = await ReadAxisAsync(motionDevice.AxisIds[jj], cancellationToken) + .ConfigureAwait(false); + axes.Add(axis); + if (!axis.AdditionalLoadId.IsNull) + { + loads.Add(await ReadLoadAsync(axis.AdditionalLoadId, cancellationToken).ConfigureAwait(false)); + } + } + for (int jj = 0; jj < motionDevice.PowerTrainIds.Count; jj++) + { + PowerTrainSnapshot powerTrain = await ReadPowerTrainAsync( + motionDevice.PowerTrainIds[jj], cancellationToken).ConfigureAwait(false); + powerTrains.Add(powerTrain); + for (int kk = 0; kk < powerTrain.MotorIds.Count; kk++) + { + MotorSnapshot motor = await ReadMotorAsync( + powerTrain.MotorIds[kk], cancellationToken).ConfigureAwait(false); + motors.Add(motor); + NodeId driveId = await ResolveChildAsync( + motor.Identification.NodeId, + RoboticsBrowseNames.DriveIdentifier_Placeholder, + cancellationToken).ConfigureAwait(false); + if (!driveId.IsNull) + { + drives.Add(await ReadDriveAsync(driveId, cancellationToken).ConfigureAwait(false)); + } + } + for (int kk = 0; kk < powerTrain.GearIds.Count; kk++) + { + gears.Add(await ReadGearAsync(powerTrain.GearIds[kk], cancellationToken) + .ConfigureAwait(false)); + } + } + } + for (int ii = 0; ii < systemSnapshot.SafetyStateIds.Count; ii++) + { + safetyStates.Add(await ReadSafetyStateAsync( + systemSnapshot.SafetyStateIds[ii], cancellationToken).ConfigureAwait(false)); + } + + var relationshipNodes = new List { system }; + relationshipNodes.AddRange(systemSnapshot.ControllerIds); + relationshipNodes.AddRange(systemSnapshot.MotionDeviceIds); + relationshipNodes.AddRange(systemSnapshot.SafetyStateIds); + for (int ii = 0; ii < controllers.Count; ii++) + { + relationshipNodes.AddRange(controllers[ii].TaskControlIds); + } + for (int ii = 0; ii < motionDevices.Count; ii++) + { + relationshipNodes.AddRange(motionDevices[ii].AxisIds); + relationshipNodes.AddRange(motionDevices[ii].PowerTrainIds); + } + for (int ii = 0; ii < powerTrains.Count; ii++) + { + relationshipNodes.AddRange(powerTrains[ii].MotorIds); + relationshipNodes.AddRange(powerTrains[ii].GearIds); + } + for (int ii = 0; ii < drives.Count; ii++) + { + relationshipNodes.Add(drives[ii].Identification.NodeId); + } + + return new RoboticsTopologySnapshot + { + Systems = [systemSnapshot], + Controllers = controllers.ToArrayOf(), + MotionDevices = motionDevices.ToArrayOf(), + Axes = axes.ToArrayOf(), + Loads = loads.ToArrayOf(), + PowerTrains = powerTrains.ToArrayOf(), + Motors = motors.ToArrayOf(), + Gears = gears.ToArrayOf(), + Drives = drives.ToArrayOf(), + SafetyStates = safetyStates.ToArrayOf(), + TaskControls = taskControls.ToArrayOf(), + TaskModules = taskModules.ToArrayOf(), + Relationships = await ReadRelationshipsAsync(relationshipNodes, cancellationToken).ConfigureAwait(false) + }; + } + + /// + /// Reads a Controller snapshot. + /// + public async Task ReadControllerAsync( + NodeId controller, + CancellationToken cancellationToken = default) + { + ControllerTypeClient proxy = new(Session, controller, Telemetry); + FolderTypeClient? taskControls = await proxy.GetTaskControlsAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + FolderTypeClient? components = await proxy.GetComponentsAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + return new ControllerSnapshot + { + Identification = await ReadIdentificationAsync(controller, cancellationToken).ConfigureAwait(false), + TaskControlIds = taskControls == null + ? [] + : await BrowseChildNodeIdsAsync(taskControls.ObjectId, cancellationToken).ConfigureAwait(false), + ComponentIds = components == null + ? [] + : await BrowseChildNodeIdsAsync(components.ObjectId, cancellationToken).ConfigureAwait(false) + }; + } + + /// + /// Reads a MotionDevice snapshot. + /// + public async Task ReadMotionDeviceAsync( + NodeId motionDevice, + CancellationToken cancellationToken = default) + { + MotionDeviceTypeClient proxy = new(Session, motionDevice, Telemetry); + FolderTypeClient? axes = await proxy.GetAxesAsync(Telemetry, cancellationToken).ConfigureAwait(false); + FolderTypeClient? powerTrains = await proxy.GetPowerTrainsAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + FolderTypeClient? additional = await proxy.GetAdditionalComponentsAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + LoadTypeClient? flangeLoad = await proxy.GetFlangeLoadAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + NodeId category = await ResolveChildAsync( + motionDevice, RoboticsBrowseNames.MotionDeviceCategory, cancellationToken).ConfigureAwait(false); + NodeId speed = await ResolveChildAsync( + motionDevice, RoboticsBrowseNames.SpeedOverride, cancellationToken).ConfigureAwait(false); + return new MotionDeviceSnapshot + { + Identification = await ReadIdentificationAsync(motionDevice, cancellationToken).ConfigureAwait(false), + Category = await ReadEnumValueAsync(category, cancellationToken) + .ConfigureAwait(false), + SpeedOverride = speed.IsNull ? DataValue.Null : await Session.ReadValueAsync(speed, cancellationToken) + .ConfigureAwait(false), + AxisIds = axes == null ? [] : await BrowseChildNodeIdsAsync(axes.ObjectId, cancellationToken) + .ConfigureAwait(false), + PowerTrainIds = powerTrains == null + ? [] + : await BrowseChildNodeIdsAsync(powerTrains.ObjectId, cancellationToken).ConfigureAwait(false), + AdditionalComponentIds = additional == null + ? [] + : await BrowseChildNodeIdsAsync(additional.ObjectId, cancellationToken).ConfigureAwait(false), + FlangeLoadId = flangeLoad?.ObjectId ?? NodeId.Null + }; + } + + /// + /// Reads an Axis snapshot. + /// + public async Task ReadAxisAsync( + NodeId axis, + CancellationToken cancellationToken = default) + { + AxisTypeClient proxy = new(Session, axis, Telemetry); + LoadTypeClient? load = await proxy.GetAdditionalLoadAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + NodeId motionProfile = await ResolveChildAsync( + axis, RoboticsBrowseNames.MotionProfile, cancellationToken).ConfigureAwait(false); + return new AxisSnapshot + { + Identification = await ReadIdentificationAsync(axis, cancellationToken).ConfigureAwait(false), + MotionProfile = await ReadEnumValueAsync( + motionProfile, cancellationToken).ConfigureAwait(false), + State = await ReadAxisStateAsync(axis, cancellationToken).ConfigureAwait(false), + AdditionalLoadId = load?.ObjectId ?? NodeId.Null + }; + } + + /// + /// Reads a SafetyState snapshot. + /// + public async Task ReadSafetyStateAsync( + NodeId safetyState, + CancellationToken cancellationToken = default) + { + SafetyStateTypeClient proxy = new(Session, safetyState, Telemetry); + FolderTypeClient? emergency = await proxy.GetEmergencyStopFunctionsAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + FolderTypeClient? protective = await proxy.GetProtectiveStopFunctionsAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + return new SafetyStateSnapshot + { + Identification = await ReadIdentificationAsync(safetyState, cancellationToken).ConfigureAwait(false), + EmergencyStop = await ReadChildValueAsync( + safetyState, RoboticsBrowseNames.EmergencyStop, cancellationToken).ConfigureAwait(false), + OperationalMode = await ReadChildValueAsync( + safetyState, RoboticsBrowseNames.OperationalMode, cancellationToken).ConfigureAwait(false), + ProtectiveStop = await ReadChildValueAsync( + safetyState, RoboticsBrowseNames.ProtectiveStop, cancellationToken).ConfigureAwait(false), + EmergencyStopFunctions = emergency == null + ? [] + : await ReadSafetyFunctionsAsync(emergency.ObjectId, cancellationToken).ConfigureAwait(false), + ProtectiveStopFunctions = protective == null + ? [] + : await ReadSafetyFunctionsAsync(protective.ObjectId, cancellationToken).ConfigureAwait(false) + }; + } + + /// + /// Reads a TaskControl snapshot. + /// + public async Task ReadTaskControlAsync( + NodeId taskControl, + CancellationToken cancellationToken = default) + { + TaskControlTypeClient proxy = new(Session, taskControl, Telemetry); + TaskControlOperationTypeClient? operation = await proxy.GetTaskControlOperationAsync( + Telemetry, cancellationToken).ConfigureAwait(false); + FolderTypeClient? modules = await proxy.GetTaskModulesAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + return new TaskControlSnapshot + { + Identification = await ReadIdentificationAsync(taskControl, cancellationToken).ConfigureAwait(false), + ExecutionMode = await ReadChildValueAsync( + taskControl, RoboticsBrowseNames.ExecutionMode, cancellationToken).ConfigureAwait(false), + TaskProgramLoaded = await ReadChildValueAsync( + taskControl, RoboticsBrowseNames.TaskProgramLoaded, cancellationToken).ConfigureAwait(false), + TaskProgramName = await ReadChildValueAsync( + taskControl, RoboticsBrowseNames.TaskProgramName, cancellationToken).ConfigureAwait(false), + TaskControlOperationId = operation?.ObjectId ?? NodeId.Null, + TaskModuleIds = modules == null + ? [] + : await BrowseChildNodeIdsAsync(modules.ObjectId, cancellationToken).ConfigureAwait(false) + }; + } + + private async Task ReadLoadAsync(NodeId load, CancellationToken cancellationToken) + { + NodeId mass = await ResolveChildAsync(load, RoboticsBrowseNames.Mass, cancellationToken) + .ConfigureAwait(false); + return new LoadSnapshot + { + NodeId = load, + Mass = mass.IsNull ? DataValue.Null : await Session.ReadValueAsync(mass, cancellationToken) + .ConfigureAwait(false), + CenterOfMass = await ReadChildValueAsync( + load, RoboticsBrowseNames.CenterOfMass, cancellationToken).ConfigureAwait(false), + Inertia = await ReadChildValueAsync(load, RoboticsBrowseNames.Inertia, cancellationToken) + .ConfigureAwait(false) + }; + } + + private async Task ReadPowerTrainAsync( + NodeId powerTrain, + CancellationToken cancellationToken) + { + PowerTrainTypeClient proxy = new(Session, powerTrain, Telemetry); + MotorTypeClient? motor = await proxy.GetMotorIdentifier_PlaceholderAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + GearTypeClient? gear = await proxy.GetGearIdentifier_PlaceholderAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + return new PowerTrainSnapshot + { + Identification = await ReadIdentificationAsync(powerTrain, cancellationToken).ConfigureAwait(false), + MotorIds = motor == null ? [] : [motor.ObjectId], + GearIds = gear == null ? [] : [gear.ObjectId] + }; + } + + private async Task ReadMotorAsync(NodeId motor, CancellationToken cancellationToken) + { + return new MotorSnapshot + { + Identification = await ReadIdentificationAsync(motor, cancellationToken).ConfigureAwait(false) + }; + } + + private async Task ReadGearAsync(NodeId gear, CancellationToken cancellationToken) + { + return new GearSnapshot + { + Identification = await ReadIdentificationAsync(gear, cancellationToken).ConfigureAwait(false), + Pitch = await ReadChildValueAsync(gear, RoboticsBrowseNames.Pitch, cancellationToken) + .ConfigureAwait(false) + }; + } + + private async Task ReadDriveAsync(NodeId drive, CancellationToken cancellationToken) + { + return new DriveSnapshot + { + Identification = await ReadIdentificationAsync(drive, cancellationToken).ConfigureAwait(false) + }; + } + + private async Task ReadTaskModuleAsync( + NodeId taskModule, + CancellationToken cancellationToken) + { + return new TaskModuleSnapshot + { + NodeId = taskModule, + Name = await ReadChildStringAsync(taskModule, RoboticsBrowseNames.Name, cancellationToken) + .ConfigureAwait(false), + Version = await ReadChildStringAsync(taskModule, "Version", cancellationToken).ConfigureAwait(false), + IsReferenced = await ReadChildValueAsync( + taskModule, RoboticsBrowseNames.IsReferenced, cancellationToken).ConfigureAwait(false) + }; + } + + /// + /// Streams axis telemetry snapshots using the managed session default subscription. + /// + public IAsyncEnumerable ObserveAxisAsync( + NodeId axis, + CancellationToken cancellationToken = default) + { + return ObserveAxisAsync(axis, GetDefaultStreaming(Session), cancellationToken); + } + + /// + /// Streams safety-state snapshots using the managed session default subscription. + /// + public IAsyncEnumerable ObserveSafetyAsync( + NodeId safetyState, + CancellationToken cancellationToken = default) + { + return ObserveSafetyAsync(safetyState, GetDefaultStreaming(Session), cancellationToken); + } + + /// + /// Streams axis telemetry snapshots over the supplied subscription. + /// + public async IAsyncEnumerable ObserveAxisAsync( + NodeId axis, + IStreamingSubscription streaming, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArrayOf nodes = await ResolveChildrenAsync( + axis, + [ + RoboticsBrowseNames.ActualPosition, + RoboticsBrowseNames.ActualSpeed, + RoboticsBrowseNames.ActualAcceleration + ], + cancellationToken).ConfigureAwait(false); + await foreach (DataValueChange _ in streaming.SubscribeDataChangesAsync( + nodes.ToList(), null, cancellationToken).ConfigureAwait(false)) + { + yield return await ReadAxisStateAsync(axis, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Streams safety-state snapshots over the supplied subscription. + /// + public async IAsyncEnumerable ObserveSafetyAsync( + NodeId safetyState, + IStreamingSubscription streaming, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArrayOf nodes = await ResolveChildrenAsync( + safetyState, + [ + RoboticsBrowseNames.EmergencyStop, + RoboticsBrowseNames.OperationalMode, + RoboticsBrowseNames.ProtectiveStop + ], + cancellationToken).ConfigureAwait(false); + await foreach (DataValueChange _ in streaming.SubscribeDataChangesAsync( + nodes.ToList(), null, cancellationToken).ConfigureAwait(false)) + { + yield return await ReadSafetyStateAsync(safetyState, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ReadSystemNodeAsync( + NodeId system, + CancellationToken cancellationToken) + { + MotionDeviceSystemTypeClient proxy = new(Session, system, Telemetry); + FolderTypeClient? controllers = await proxy.GetControllersAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + FolderTypeClient? motionDevices = await proxy.GetMotionDevicesAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + FolderTypeClient? safetyStates = await proxy.GetSafetyStatesAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + return new MotionDeviceSystemSnapshot + { + Identification = await ReadIdentificationAsync(system, cancellationToken).ConfigureAwait(false), + ControllerIds = controllers == null + ? [] + : await BrowseChildNodeIdsAsync(controllers.ObjectId, cancellationToken).ConfigureAwait(false), + MotionDeviceIds = motionDevices == null + ? [] + : await BrowseChildNodeIdsAsync(motionDevices.ObjectId, cancellationToken).ConfigureAwait(false), + SafetyStateIds = safetyStates == null + ? [] + : await BrowseChildNodeIdsAsync(safetyStates.ObjectId, cancellationToken).ConfigureAwait(false) + }; + } + + private async Task ReadAxisStateAsync( + NodeId axis, + CancellationToken cancellationToken) + { + ArrayOf nodes = await ResolveChildrenAsync( + axis, + [ + RoboticsBrowseNames.ActualPosition, + RoboticsBrowseNames.ActualSpeed, + RoboticsBrowseNames.ActualAcceleration + ], + cancellationToken).ConfigureAwait(false); + ArrayOf values = await ReadValuesAsync(nodes.ToList(), cancellationToken) + .ConfigureAwait(false); + return new AxisStateSnapshot + { + ActualPosition = values.Count > 0 ? values[0] : DataValue.Null, + ActualSpeed = values.Count > 1 ? values[1] : DataValue.Null, + ActualAcceleration = values.Count > 2 ? values[2] : DataValue.Null + }; + } + + private async Task> ReadSafetyFunctionsAsync( + NodeId folder, + CancellationToken cancellationToken) + { + ArrayOf functionIds = await BrowseChildNodeIdsAsync(folder, cancellationToken) + .ConfigureAwait(false); + var snapshots = new List(functionIds.Count); + for (int ii = 0; ii < functionIds.Count; ii++) + { + NodeId functionId = functionIds[ii]; + snapshots.Add(new SafetyFunctionSnapshot + { + NodeId = functionId, + Name = await ReadChildStringAsync( + functionId, RoboticsBrowseNames.Name, cancellationToken).ConfigureAwait(false), + Active = await ReadChildValueAsync( + functionId, RoboticsBrowseNames.Active, cancellationToken).ConfigureAwait(false), + Enabled = await ReadChildValueAsync( + functionId, RoboticsBrowseNames.Enabled, cancellationToken).ConfigureAwait(false) + }); + } + return snapshots.ToArrayOf(); + } + + private async Task ReadIdentificationAsync( + NodeId nodeId, + CancellationToken cancellationToken) + { + ArrayOf properties = await ResolveChildrenAsync( + nodeId, + [ + DiBrowseNames.ComponentName, + DiBrowseNames.AssetId, + DiBrowseNames.Manufacturer, + DiBrowseNames.Model, + DiBrowseNames.ProductCode, + DiBrowseNames.SerialNumber, + DiBrowseNames.DeviceManual + ], + cancellationToken).ConfigureAwait(false); + var readIds = new List + { + new() { NodeId = nodeId, AttributeId = Attributes.BrowseName }, + new() { NodeId = nodeId, AttributeId = Attributes.DisplayName } + }; + for (int ii = 0; ii < properties.Count; ii++) + { + if (!properties[ii].IsNull) + { + readIds.Add(new ReadValueId { NodeId = properties[ii], AttributeId = Attributes.Value }); + } + } + ArrayOf nodesToRead = readIds.ToArrayOf(); + ReadResponse response = await Session.ReadAsync( + null, + 0, + TimestampsToReturn.Both, + nodesToRead, + cancellationToken).ConfigureAwait(false); + ClientBase.ValidateResponse(response.Results, nodesToRead); + + QualifiedName browseName = response.Results.Count > 0 && + response.Results[0].WrappedValue.TryGetValue(out QualifiedName qn) + ? qn + : QualifiedName.Null; + LocalizedText componentName = response.Results.Count > 1 && + response.Results[1].WrappedValue.TryGetValue(out LocalizedText lt) + ? lt + : LocalizedText.Null; + var values = new Dictionary(); + int index = 2; + for (int ii = 0; ii < properties.Count; ii++) + { + if (!properties[ii].IsNull && index < response.Results.Count) + { + values[properties[ii]] = response.Results[index++]; + } + } + return new RoboticsComponentIdentification + { + NodeId = nodeId, + BrowseName = browseName, + ComponentName = ReadLocalized(properties, values, 0, componentName), + AssetId = ReadString(properties, values, 1), + Manufacturer = ReadLocalized(properties, values, 2, LocalizedText.Null), + Model = ReadLocalized(properties, values, 3, LocalizedText.Null), + ProductCode = ReadString(properties, values, 4), + SerialNumber = ReadString(properties, values, 5), + DeviceManual = ReadString(properties, values, 6) + }; + } + + private async Task ReadRelationshipsAsync( + IReadOnlyList sources, + CancellationToken cancellationToken) + { + int ns = Session.NamespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics); + if (ns < 0) + { + return new RoboticsRelationshipSnapshot(); + } + var controls = new List(); + var requires = new List(); + var moves = new List(); + var isDrivenBy = new List(); + var hasSlave = new List(); + var isConnectedTo = new List(); + var hasSafetyStates = new List(); + await AddRelationshipsAsync(sources, ReferenceTypes.Controls, controls, cancellationToken) + .ConfigureAwait(false); + await AddRelationshipsAsync(sources, ReferenceTypes.Requires, requires, cancellationToken) + .ConfigureAwait(false); + await AddRelationshipsAsync(sources, ReferenceTypes.Moves, moves, cancellationToken) + .ConfigureAwait(false); + await AddRelationshipsAsync(sources, ReferenceTypes.IsDrivenBy, isDrivenBy, cancellationToken) + .ConfigureAwait(false); + await AddRelationshipsAsync(sources, ReferenceTypes.HasSlave, hasSlave, cancellationToken) + .ConfigureAwait(false); + await AddRelationshipsAsync(sources, ReferenceTypes.IsConnectedTo, isConnectedTo, cancellationToken) + .ConfigureAwait(false); + await AddRelationshipsAsync(sources, ReferenceTypes.HasSafetyStates, hasSafetyStates, cancellationToken) + .ConfigureAwait(false); + return new RoboticsRelationshipSnapshot + { + Controls = controls.ToArrayOf(), + Requires = requires.ToArrayOf(), + Moves = moves.ToArrayOf(), + IsDrivenBy = isDrivenBy.ToArrayOf(), + HasSlave = hasSlave.ToArrayOf(), + IsConnectedTo = isConnectedTo.ToArrayOf(), + HasSafetyStates = hasSafetyStates.ToArrayOf() + }; + } + + private async Task AddRelationshipsAsync( + IReadOnlyList sources, + uint referenceTypeIdentifier, + List entries, + CancellationToken cancellationToken) + { + int ns = Session.NamespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics); + if (ns < 0) + { + return; + } + var referenceType = new NodeId(referenceTypeIdentifier, (ushort)ns); + for (int ii = 0; ii < sources.Count; ii++) + { + NodeId source = sources[ii]; + if (source.IsNull) + { + continue; + } + ArrayOf references = await BrowseReferencesAsync( + source, referenceType, BrowseDirection.Both, cancellationToken).ConfigureAwait(false); + for (int jj = 0; jj < references.Count; jj++) + { + ReferenceDescription reference = references[jj]; + NodeId target = ExpandedNodeId.ToNodeId(reference.NodeId, Session.NamespaceUris); + if (target.IsNull) + { + continue; + } + entries.Add(new RoboticsRelationshipEntry + { + SourceId = source, + ReferenceTypeId = referenceType, + TargetId = target, + IsInverse = !reference.IsForward + }); + } + } + } + + private async Task> ResolveChildrenAsync( + NodeId parent, + IReadOnlyList browseNames, + CancellationToken cancellationToken) + { + var paths = new List(browseNames.Count); + for (int ii = 0; ii < browseNames.Count; ii++) + { + paths.Add(CreateBrowsePath(parent, browseNames[ii])); + } + TranslateBrowsePathsToNodeIdsResponse response = await Session.TranslateBrowsePathsToNodeIdsAsync( + null, + paths.ToArrayOf(), + cancellationToken).ConfigureAwait(false); + var results = new List(browseNames.Count); + for (int ii = 0; ii < response.Results.Count; ii++) + { + BrowsePathResult result = response.Results[ii]; + results.Add(StatusCode.IsGood(result.StatusCode) && result.Targets.Count > 0 + ? ExpandedNodeId.ToNodeId(result.Targets[0].TargetId, Session.NamespaceUris) + : NodeId.Null); + } + while (results.Count < browseNames.Count) + { + results.Add(NodeId.Null); + } + return results.ToArrayOf(); + } + + private async ValueTask ResolveChildAsync( + NodeId parent, + string browseName, + CancellationToken cancellationToken) + { + ArrayOf nodes = await ResolveChildrenAsync(parent, [browseName], cancellationToken) + .ConfigureAwait(false); + return nodes.Count > 0 ? nodes[0] : NodeId.Null; + } + + private BrowsePath CreateBrowsePath(NodeId parent, string browseName) + { + return new BrowsePath + { + StartingNode = parent, + RelativePath = new RelativePath + { + Elements = + [ + new RelativePathElement + { + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName(browseName) + } + ] + } + }; + } + + private async Task ReadChildValueAsync( + NodeId parent, + string browseName, + CancellationToken cancellationToken) + { + NodeId nodeId = await ResolveChildAsync(parent, browseName, cancellationToken).ConfigureAwait(false); + return nodeId.IsNull + ? DataValue.Null + : await Session.ReadValueAsync(nodeId, cancellationToken).ConfigureAwait(false); + } + + private async Task ReadChildStringAsync( + NodeId parent, + string browseName, + CancellationToken cancellationToken) + { + DataValue value = await ReadChildValueAsync(parent, browseName, cancellationToken).ConfigureAwait(false); + return value.WrappedValue.TryGetValue(out string text) ? text : null; + } + + private async Task ReadEnumValueAsync(NodeId nodeId, CancellationToken cancellationToken) + where T : struct + { + if (nodeId.IsNull) + { + return default; + } + DataValue value = await Session.ReadValueAsync(nodeId, cancellationToken).ConfigureAwait(false); + if (value.WrappedValue.TryGetValue(out int intValue)) + { + return (T)System.Enum.ToObject(typeof(T), intValue); + } + if (value.WrappedValue.TryGetValue(out uint uintValue)) + { + return (T)System.Enum.ToObject(typeof(T), uintValue); + } + return default; + } + + private async Task> ReadValuesAsync( + List nodeIds, + CancellationToken cancellationToken) + { + var nodes = new List(nodeIds.Count); + for (int ii = 0; ii < nodeIds.Count; ii++) + { + nodes.Add(new ReadValueId { NodeId = nodeIds[ii], AttributeId = Attributes.Value }); + } + if (nodes.Count == 0) + { + return []; + } + ArrayOf nodesToRead = nodes.ToArrayOf(); + ReadResponse response = await Session.ReadAsync( + null, + 0, + TimestampsToReturn.Both, + nodesToRead, + cancellationToken).ConfigureAwait(false); + ClientBase.ValidateResponse(response.Results, nodesToRead); + return response.Results; + } + + private async Task> BrowseChildNodeIdsAsync( + NodeId parent, + CancellationToken cancellationToken) + { + ArrayOf references = await BrowseObjectsAsync(parent, cancellationToken) + .ConfigureAwait(false); + var nodes = new List(references.Count); + for (int ii = 0; ii < references.Count; ii++) + { + NodeId nodeId = ExpandedNodeId.ToNodeId(references[ii].NodeId, Session.NamespaceUris); + if (!nodeId.IsNull) + { + nodes.Add(nodeId); + } + } + return nodes.ToArrayOf(); + } + + private Task> BrowseObjectsAsync( + NodeId parent, + CancellationToken cancellationToken) + { + return BrowseObjectsAsync(parent, Opc.Ua.ReferenceTypeIds.HierarchicalReferences, cancellationToken); + } + + private Task> BrowseObjectsAsync( + NodeId parent, + NodeId referenceTypeId, + CancellationToken cancellationToken) + { + return BrowseReferencesAsync( + parent, + referenceTypeId, + BrowseDirection.Forward, + cancellationToken, + (uint)NodeClass.Object); + } + + private async Task> BrowseReferencesAsync( + NodeId parent, + NodeId referenceTypeId, + BrowseDirection direction, + CancellationToken cancellationToken, + uint nodeClassMask = 0) + { + (ArrayOf> results, ArrayOf errors) = + await Session.ManagedBrowseAsync( + null, + null, + [parent], + 0, + direction, + referenceTypeId, + true, + nodeClassMask, + cancellationToken).ConfigureAwait(false); + if (errors.Count > 0 && ServiceResult.IsBad(errors[0])) + { + return []; + } + return results.Count > 0 ? results[0] : []; + } + + private static LocalizedText ReadLocalized( + ArrayOf properties, + Dictionary values, + int propertyIndex, + LocalizedText fallback) + { + if (propertyIndex < properties.Count && !properties[propertyIndex].IsNull && + values.TryGetValue(properties[propertyIndex], out DataValue value) && + value.WrappedValue.TryGetValue(out LocalizedText text)) + { + return text; + } + return fallback; + } + + private static string? ReadString( + ArrayOf properties, + Dictionary values, + int propertyIndex) + { + if (propertyIndex < properties.Count && !properties[propertyIndex].IsNull && + values.TryGetValue(properties[propertyIndex], out DataValue value) && + value.WrappedValue.TryGetValue(out string text)) + { + return text; + } + return null; + } + } +} diff --git a/src/Opc.Ua.Robotics.Client/RoboticsClient.cs b/src/Opc.Ua.Robotics.Client/RoboticsClient.cs new file mode 100644 index 0000000000..093213e6eb --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/RoboticsClient.cs @@ -0,0 +1,358 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Di.Client; + +namespace Opc.Ua.Robotics.Client +{ + /// + /// Client-side helpers for the OPC 40010 Robotics companion model. Robotics + /// types derive from OPC 40001-1 Industrial Automation, which derives from + /// OPC 10000-100 Device Integration, so this client composes the DI client + /// () instead of reimplementing device + /// discovery. A generic OpenUSD connector or viewer uses it to discover + /// MotionDeviceSystem instances and to identify the Robotics type of a node + /// it discovered, so it can label and drive a robot-cell twin without + /// hard-coding NodeIds. + /// + /// + /// Every discovery and classification operation is subtype aware: a robot + /// vendor that specialises the companion types (for example an + /// AcmeMotionDeviceType derived from MotionDeviceType) is + /// discovered and classified as the closest standard Robotics type. + /// + public sealed partial class RoboticsClient + { + /// + /// Creates a Robotics client over a connected session. + /// + /// + /// The connected session. + /// + /// + /// The telemetry context used for logging. + /// + public RoboticsClient(ISession session, ITelemetryContext telemetry) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + Telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + Topology = new DiTopologyClient(session, telemetry); + } + + /// + /// Gets the connected session. + /// + public ISession Session { get; } + + /// + /// Gets the telemetry context. + /// + public ITelemetryContext Telemetry { get; } + + /// + /// Gets the Device Integration topology client this Robotics client + /// extends. Use it to walk the DI device topology that carries the + /// Robotics instances. + /// + public DiTopologyClient Topology { get; } + + /// + /// Discovers every MotionDeviceSystem below the DI DeviceSet, + /// including instances of vendor subtypes. + /// + /// + /// Cancels the operation. + /// + public Task> DiscoverMotionDeviceSystemsAsync( + CancellationToken cancellationToken = default) + { + return DiscoverMotionDeviceSystemsAsync( + Topology.DeviceSetId, cancellationToken); + } + + /// + /// Discovers every MotionDeviceSystem below , + /// including instances of vendor subtypes. + /// + /// + /// The node to browse, typically the DI DeviceSet or the server + /// Objects folder. + /// + /// + /// Cancels the operation. + /// + public Task> DiscoverMotionDeviceSystemsAsync( + NodeId root, + CancellationToken cancellationToken = default) + { + return DiscoverAsync( + Session, root, RoboticsModel.MotionDeviceSystemType, cancellationToken); + } + + /// + /// Discovers every MotionDevice below , which is + /// typically the MotionDevices folder of a MotionDeviceSystem. + /// Instances of vendor subtypes are included. + /// + /// + /// The node to browse. + /// + /// + /// Cancels the operation. + /// + public Task> DiscoverMotionDevicesAsync( + NodeId root, + CancellationToken cancellationToken = default) + { + return DiscoverAsync( + Session, root, RoboticsModel.MotionDeviceType, cancellationToken); + } + + /// + /// Discovers every Controller below , which is + /// typically the Controllers folder of a MotionDeviceSystem. + /// Instances of vendor subtypes are included. + /// + /// + /// The node to browse. + /// + /// + /// Cancels the operation. + /// + public Task> DiscoverControllersAsync( + NodeId root, + CancellationToken cancellationToken = default) + { + return DiscoverAsync( + Session, root, RoboticsModel.ControllerType, cancellationToken); + } + + /// + /// Discovers every Axis below , which is + /// typically the Axes folder of a MotionDevice. Instances of + /// vendor subtypes are included. + /// + /// + /// The node to browse. + /// + /// + /// Cancels the operation. + /// + public Task> DiscoverAxesAsync( + NodeId root, + CancellationToken cancellationToken = default) + { + return DiscoverAsync( + Session, root, RoboticsModel.AxisType, cancellationToken); + } + + /// + /// Maps a node's TypeDefinition to the friendly name of the closest + /// standard Robotics type (MotionDeviceSystem, + /// MotionDevice, Axis, Controller), following the + /// server's type hierarchy so vendor subtypes are classified too. + /// Returns null when the node is not a Robotics type. + /// + /// + /// The TypeDefinition NodeId of a discovered node. + /// + /// + /// Cancels the operation. + /// + public async Task GetRoboticsTypeNameAsync( + NodeId typeDefinition, + CancellationToken cancellationToken = default) + { + if (typeDefinition.IsNull) + { + return null; + } + if (TryGetRoboticsTypeName( + typeDefinition, Session.NamespaceUris, out string? exact)) + { + return exact; + } + + // Probe the standard Robotics types most-derived first so a vendor + // subtype resolves to its closest standard base. + foreach ((uint identifier, string name) in s_classification) + { + NodeId candidate = RoboticsModel.TypeNodeId( + identifier, Session.NamespaceUris); + if (!candidate.IsNull && + await Session.NodeCache.IsTypeOfAsync( + typeDefinition, candidate, cancellationToken) + .ConfigureAwait(false)) + { + return name; + } + } + return null; + } + + /// + /// Browses the hierarchical children of and + /// returns the NodeIds of those whose TypeDefinition is, or derives + /// from, the Robotics MotionDeviceSystemType. + /// is typically the DI DeviceSet or the + /// server Objects folder. + /// + /// + /// The connected session. + /// + /// + /// The node to browse. + /// + /// + /// Cancels the operation. + /// + public static Task> DiscoverMotionDeviceSystemsAsync( + ISession session, NodeId root, CancellationToken cancellationToken = default) + { + return DiscoverAsync( + session, root, RoboticsModel.MotionDeviceSystemType, cancellationToken); + } + + /// + /// Maps a node's TypeDefinition to a friendly Robotics type name + /// (MotionDeviceSystem, MotionDevice, Axis, + /// Controller), or returns false if it is not a known + /// Robotics type in the supplied namespace table. This is an exact + /// match that needs no server round-trip; use + /// to classify vendor subtypes. + /// + /// + /// The TypeDefinition NodeId of a discovered node. + /// + /// + /// The namespace table that resolves the Robotics namespace index. + /// + /// + /// Receives the friendly type name when the method returns true. + /// + public static bool TryGetRoboticsTypeName( + NodeId typeDefinition, NamespaceTable namespaceUris, out string? name) + { + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + name = null; + int ns = namespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics); + if (ns < 0 || typeDefinition.IsNull) + { + // The Robotics namespace is not present in the table, so no node can be + // a known Robotics type. Return false instead of throwing. + return false; + } + var robotics = (ushort)ns; + if (typeDefinition == new NodeId(RoboticsModel.MotionDeviceSystemType, robotics)) + { + name = "MotionDeviceSystem"; + } + else if (typeDefinition == new NodeId(RoboticsModel.MotionDeviceType, robotics)) + { + name = "MotionDevice"; + } + else if (typeDefinition == new NodeId(RoboticsModel.AxisType, robotics)) + { + name = "Axis"; + } + else if (typeDefinition == new NodeId(RoboticsModel.ControllerType, robotics)) + { + name = "Controller"; + } + return name != null; + } + + private static async Task> DiscoverAsync( + ISession session, + NodeId root, + uint typeIdentifier, + CancellationToken cancellationToken) + { + if (session is null) + { + throw new ArgumentNullException(nameof(session)); + } + + int ns = session.NamespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics); + if (ns < 0) + { + // The server does not expose the Robotics companion namespace. + return ArrayOf.Empty; + } + var wantedType = new NodeId(typeIdentifier, (ushort)ns); + + // ManagedBrowseAsync follows continuation points, so a server that caps the + // number of references it returns per node cannot silently truncate discovery. + (ArrayOf> results, _) = await session.ManagedBrowseAsync( + null, null, [root], 0, BrowseDirection.Forward, + Opc.Ua.ReferenceTypeIds.HierarchicalReferences, includeSubtypes: true, + (uint)NodeClass.Object, cancellationToken).ConfigureAwait(false); + + var matches = new List(); + if (results.Count > 0) + { + ArrayOf refs = results[0]; + for (int i = 0; i < refs.Count; i++) + { + ReferenceDescription r = refs[i]; + NodeId typeDef = ExpandedNodeId.ToNodeId(r.TypeDefinition, session.NamespaceUris); + NodeId child = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris); + if (typeDef.IsNull || child.IsNull) + { + continue; + } + // Accept vendor specialisations of the companion type as well; + // IsTypeOfAsync short-circuits on an exact match. + if (await session.NodeCache.IsTypeOfAsync( + typeDef, wantedType, cancellationToken).ConfigureAwait(false)) + { + matches.Add(child); + } + } + } + return matches; + } + + private static readonly (uint Identifier, string Name)[] s_classification = + [ + (RoboticsModel.AxisType, "Axis"), + (RoboticsModel.ControllerType, "Controller"), + (RoboticsModel.MotionDeviceType, "MotionDevice"), + (RoboticsModel.MotionDeviceSystemType, "MotionDeviceSystem") + ]; + } +} diff --git a/src/Opc.Ua.Robotics.Client/RoboticsClientFactory.cs b/src/Opc.Ua.Robotics.Client/RoboticsClientFactory.cs new file mode 100644 index 0000000000..f8274867c3 --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/RoboticsClientFactory.cs @@ -0,0 +1,74 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; + +namespace Opc.Ua.Robotics.Client +{ + /// + /// Session-bound factory for creating Robotics clients through DI. + /// + public sealed class RoboticsClientFactory + { + private readonly Func> m_sessionFactory; + private readonly ITelemetryContext m_telemetry; + + /// + /// Creates a Robotics client factory. + /// + /// + /// The managed-session factory registered by AddClient. + /// + /// + /// The telemetry context used by generated proxies. + /// + public RoboticsClientFactory( + Func> sessionFactory, + ITelemetryContext telemetry) + { + m_sessionFactory = sessionFactory ?? throw new ArgumentNullException(nameof(sessionFactory)); + m_telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + } + + /// + /// Creates a Robotics client over the current managed session. + /// + /// + /// Cancels session acquisition. + /// + public async Task CreateAsync(CancellationToken cancellationToken = default) + { + ManagedSession session = await m_sessionFactory(cancellationToken).ConfigureAwait(false); + return new RoboticsClient(session, m_telemetry); + } + } +} diff --git a/src/Opc.Ua.Robotics.Client/SessionRoboticsExtensions.cs b/src/Opc.Ua.Robotics.Client/SessionRoboticsExtensions.cs new file mode 100644 index 0000000000..b07fd74e7b --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/SessionRoboticsExtensions.cs @@ -0,0 +1,62 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Client; + +namespace Opc.Ua.Robotics.Client +{ + /// + /// Convenience extensions for opening high-level Robotics clients from a session. + /// + public static class SessionRoboticsExtensions + { + /// + /// Creates a high-level Robotics client over the supplied session. + /// + /// + /// The connected session. + /// + /// + /// The telemetry context used by generated proxies. + /// + public static RoboticsClient Robotics(this ISession session, ITelemetryContext telemetry) + { + if (session is null) + { + throw new ArgumentNullException(nameof(session)); + } + if (telemetry is null) + { + throw new ArgumentNullException(nameof(telemetry)); + } + return new RoboticsClient(session, telemetry); + } + } +} diff --git a/src/Opc.Ua.Robotics.Client/SystemOperationClient.cs b/src/Opc.Ua.Robotics.Client/SystemOperationClient.cs new file mode 100644 index 0000000000..bb0ae74540 --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/SystemOperationClient.cs @@ -0,0 +1,206 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; +using Opc.Ua.Client.StateMachines; +using Opc.Ua.Client.Subscriptions.Streaming; + +namespace Opc.Ua.Robotics.Client +{ + /// + /// High-level client for a controller SystemOperation object. + /// + public sealed class SystemOperationClient + { + private readonly SystemOperationTypeClient? m_operation; + private readonly NodeId m_controllerNodeId; + private SystemOperationStateMachineTypeClient? m_stateMachine; + + /// + /// Creates a SystemOperation client. + /// + public SystemOperationClient(ISession session, NodeId operationNodeId, ITelemetryContext telemetry) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + OperationNodeId = operationNodeId.IsNull + ? throw new ArgumentException("An operation NodeId is required.", nameof(operationNodeId)) + : operationNodeId; + Telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + m_operation = new SystemOperationTypeClient(Session, OperationNodeId, Telemetry); + m_controllerNodeId = NodeId.Null; + } + + internal SystemOperationClient( + ISession session, + NodeId controllerNodeId, + ITelemetryContext telemetry, + bool resolveFromController) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + m_controllerNodeId = controllerNodeId.IsNull + ? throw new ArgumentException("A controller NodeId is required.", nameof(controllerNodeId)) + : controllerNodeId; + Telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + OperationNodeId = NodeId.Null; + m_operation = null!; + } + + /// + /// Gets the connected session. + /// + public ISession Session { get; } + + /// + /// Gets the SystemOperation object NodeId. + /// + public NodeId OperationNodeId { get; } + + /// + /// Gets the telemetry context. + /// + public ITelemetryContext Telemetry { get; } + + /// + /// Calls GetReady on the generated SystemOperation state-machine proxy. + /// + public async ValueTask GetReadyAsync(CancellationToken cancellationToken = default) + { + return await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .GetReadyAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Calls Start on the generated SystemOperation state-machine proxy. + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + return await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .StartAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Calls Stop on the generated SystemOperation state-machine proxy. + /// + public async ValueTask StopAsync( + RoboticsStopMode stopMode, + CancellationToken cancellationToken = default) + { + return await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .StopAsync((long)stopMode, cancellationToken).ConfigureAwait(false); + } + + /// + /// Calls StandDown on the generated SystemOperation state-machine proxy. + /// + public async ValueTask StandDownAsync(CancellationToken cancellationToken = default) + { + return await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .StandDownAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads the current operation state. + /// + public async ValueTask ReadStateAsync( + CancellationToken cancellationToken = default) + { + FiniteStateSnapshot snapshot = await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .GetCurrentFiniteStateAsync(cancellationToken).ConfigureAwait(false); + return ToOperationState(snapshot.CurrentState); + } + + /// + /// Streams current operation-state changes using the managed session default subscription. + /// + public IAsyncEnumerable ObserveStateAsync( + CancellationToken cancellationToken = default) + { + return ObserveStateAsync(RoboticsClient.GetDefaultStreaming(Session), cancellationToken); + } + + /// + /// Streams current operation-state changes over the supplied subscription. + /// + public async IAsyncEnumerable ObserveStateAsync( + IStreamingSubscription streaming, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (FiniteStateSnapshot snapshot in (await StateMachineAsync(cancellationToken) + .ConfigureAwait(false)).ObserveFiniteTransitionsAsync(streaming, null, cancellationToken) + .ConfigureAwait(false)) + { + yield return ToOperationState(snapshot.CurrentState); + } + } + + private async ValueTask StateMachineAsync( + CancellationToken cancellationToken) + { + if (m_stateMachine != null) + { + return m_stateMachine; + } + SystemOperationTypeClient? operation = m_operation; + if (operation == null) + { + ControllerTypeClient controller = new(Session, m_controllerNodeId, Telemetry); + operation = await controller.GetSystemOperationAsync(Telemetry, cancellationToken) + .ConfigureAwait(false) ?? throw new ServiceResultException( + StatusCodes.BadNotFound, + $"Controller '{m_controllerNodeId}' does not expose SystemOperation."); + } + SystemOperationStateMachineTypeClient? stateMachine = await operation + .GetSystemOperationStateMachineAsync(Telemetry, cancellationToken).ConfigureAwait(false); + if (stateMachine == null) + { + throw new ServiceResultException( + StatusCodes.BadNotFound, + $"SystemOperation '{OperationNodeId}' does not expose SystemOperationStateMachine."); + } + m_stateMachine = stateMachine; + return stateMachine; + } + + private static RoboticsOperationState ToOperationState(LocalizedText state) + { + string? text = state.Text; + return text switch + { + BrowseNames.Ready => RoboticsOperationState.Ready, + BrowseNames.Executing => RoboticsOperationState.Executing, + _ => RoboticsOperationState.Idle + }; + } + } +} diff --git a/src/Opc.Ua.Robotics.Client/TaskControlClient.cs b/src/Opc.Ua.Robotics.Client/TaskControlClient.cs new file mode 100644 index 0000000000..fba482f623 --- /dev/null +++ b/src/Opc.Ua.Robotics.Client/TaskControlClient.cs @@ -0,0 +1,260 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; +using Opc.Ua.Client.StateMachines; +using Opc.Ua.Client.Subscriptions.Streaming; + +namespace Opc.Ua.Robotics.Client +{ + /// + /// High-level client for a TaskControl object and its operation state machine. + /// + public sealed class TaskControlClient + { + private readonly TaskControlTypeClient m_taskControl; + private TaskControlOperationTypeClient? m_operation; + private TaskControlStateMachineTypeClient? m_stateMachine; + + /// + /// Creates a TaskControl client. + /// + public TaskControlClient(ISession session, NodeId taskControlNodeId, ITelemetryContext telemetry) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + TaskControlNodeId = taskControlNodeId.IsNull + ? throw new ArgumentException("A task-control NodeId is required.", nameof(taskControlNodeId)) + : taskControlNodeId; + Telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + m_taskControl = new TaskControlTypeClient(Session, TaskControlNodeId, Telemetry); + } + + /// + /// Gets the connected session. + /// + public ISession Session { get; } + + /// + /// Gets the TaskControl object NodeId. + /// + public NodeId TaskControlNodeId { get; } + + /// + /// Gets the telemetry context. + /// + public ITelemetryContext Telemetry { get; } + + /// + /// Loads a program by name. + /// + public async ValueTask LoadByNameAsync( + string name, + CancellationToken cancellationToken = default) + { + int status = await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .LoadByNameAsync(name ?? string.Empty, cancellationToken).ConfigureAwait(false); + return new RoboticsProgramResult { Status = status }; + } + + /// + /// Loads a program by NodeId. + /// + public async ValueTask LoadByNodeIdAsync( + NodeId nodeId, + CancellationToken cancellationToken = default) + { + int status = await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .LoadByNodeIdAsync(nodeId, cancellationToken).ConfigureAwait(false); + return new RoboticsProgramResult { Status = status }; + } + + /// + /// Unloads a program by name. + /// + public async ValueTask UnloadByNameAsync( + string name, + CancellationToken cancellationToken = default) + { + int status = await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .UnloadByNameAsync(name ?? string.Empty, cancellationToken).ConfigureAwait(false); + return new RoboticsProgramResult { Status = status }; + } + + /// + /// Unloads a program by NodeId. + /// + public async ValueTask UnloadByNodeIdAsync( + NodeId nodeId, + CancellationToken cancellationToken = default) + { + int status = await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .UnloadByNodeIdAsync(nodeId, cancellationToken).ConfigureAwait(false); + return new RoboticsProgramResult { Status = status }; + } + + /// + /// Unloads the current program. + /// + public async ValueTask UnloadProgramAsync( + CancellationToken cancellationToken = default) + { + int status = await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .UnloadProgramAsync(cancellationToken).ConfigureAwait(false); + return new RoboticsProgramResult { Status = status }; + } + + /// + /// Resets execution to the start of the loaded program. + /// + public async ValueTask ResetToProgramStartAsync(CancellationToken cancellationToken = default) + { + TaskControlStateMachineTypeClient stateMachine = await StateMachineAsync(cancellationToken) + .ConfigureAwait(false); + StateTypeClient? ready = await stateMachine.GetReadyAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + if (ready == null) + { + throw new ServiceResultException( + StatusCodes.BadNotFound, + $"TaskControl '{TaskControlNodeId}' does not expose Ready."); + } + ReadySubstateMachineTypeClient readySubstate = new(Session, ready.ObjectId, Telemetry); + return await readySubstate.ResetToProgramStartAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Starts task execution. + /// + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + return await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .StartAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Stops task execution. + /// + public async ValueTask StopAsync( + RoboticsStopMode stopMode, + CancellationToken cancellationToken = default) + { + return await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .StopAsync((long)stopMode, cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads the current task-control operation state. + /// + public async ValueTask ReadStateAsync( + CancellationToken cancellationToken = default) + { + FiniteStateSnapshot snapshot = await (await StateMachineAsync(cancellationToken).ConfigureAwait(false)) + .GetCurrentFiniteStateAsync(cancellationToken).ConfigureAwait(false); + return ToOperationState(snapshot.CurrentState); + } + + /// + /// Streams task-control operation-state changes using the managed session default subscription. + /// + public IAsyncEnumerable ObserveStateAsync( + CancellationToken cancellationToken = default) + { + return ObserveStateAsync(RoboticsClient.GetDefaultStreaming(Session), cancellationToken); + } + + /// + /// Streams task-control operation-state changes over the supplied subscription. + /// + public async IAsyncEnumerable ObserveStateAsync( + IStreamingSubscription streaming, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (FiniteStateSnapshot snapshot in (await StateMachineAsync(cancellationToken) + .ConfigureAwait(false)).ObserveFiniteTransitionsAsync(streaming, null, cancellationToken) + .ConfigureAwait(false)) + { + yield return ToOperationState(snapshot.CurrentState); + } + } + + private async ValueTask OperationAsync(CancellationToken cancellationToken) + { + if (m_operation != null) + { + return m_operation; + } + TaskControlOperationTypeClient? operation = await m_taskControl + .GetTaskControlOperationAsync(Telemetry, cancellationToken).ConfigureAwait(false); + if (operation == null) + { + throw new ServiceResultException( + StatusCodes.BadNotFound, + $"TaskControl '{TaskControlNodeId}' does not expose TaskControlOperation."); + } + m_operation = operation; + return operation; + } + + private async ValueTask StateMachineAsync( + CancellationToken cancellationToken) + { + if (m_stateMachine != null) + { + return m_stateMachine; + } + TaskControlStateMachineTypeClient? stateMachine = await (await OperationAsync(cancellationToken) + .ConfigureAwait(false)).GetTaskControlStateMachineAsync(Telemetry, cancellationToken) + .ConfigureAwait(false); + if (stateMachine == null) + { + throw new ServiceResultException( + StatusCodes.BadNotFound, + $"TaskControl '{TaskControlNodeId}' does not expose TaskControlStateMachine."); + } + m_stateMachine = stateMachine; + return stateMachine; + } + + private static RoboticsOperationState ToOperationState(LocalizedText state) + { + string? text = state.Text; + return text switch + { + BrowseNames.Ready => RoboticsOperationState.Ready, + BrowseNames.Executing => RoboticsOperationState.Executing, + _ => RoboticsOperationState.Idle + }; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/IProgramsBuilder.cs b/src/Opc.Ua.Robotics.Server/Builders/IProgramsBuilder.cs new file mode 100644 index 0000000000..b683cb59e1 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/IProgramsBuilder.cs @@ -0,0 +1,75 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Server.FileSystem; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Configures the optional OPC 40010 Controller Programs directory. + /// The directory is a standard Part 5 FileDirectoryType, so it is + /// backed by the stack's existing model + /// rather than by a Robotics-specific file abstraction. + /// + public interface IProgramsBuilder + { + /// + /// Gets the Programs directory being configured. + /// + FileDirectoryState Directory { get; } + + /// + /// Backs the Programs directory with an explicit provider instance. + /// + /// + /// The file-system provider serving the controller's programs. + /// + IProgramsBuilder UseFileSystem(IFileSystemProvider provider); + + /// + /// Backs the Programs directory with a provider resolved from the + /// application service provider. + /// + /// + /// The registered provider contract. + /// + IProgramsBuilder UseFileSystem() + where TProvider : class, IFileSystemProvider; + + /// + /// Applies binding options such as the create, delete, and move + /// permissions and the materialisation guards. + /// + /// + /// Receives the options applied when the directory is bound. + /// + IProgramsBuilder WithOptions(Action configure); + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/IRoboticsNodeBuilder.cs b/src/Opc.Ua.Robotics.Server/Builders/IRoboticsNodeBuilder.cs new file mode 100644 index 0000000000..f3a818c4a7 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/IRoboticsNodeBuilder.cs @@ -0,0 +1,65 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Server.Fluent; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Common fluent surface for a Robotics node being assembled. + /// + /// The generated Robotics state type. + public interface IRoboticsNodeBuilder + where TState : NodeState + { + /// + /// Gets the generated state being configured. + /// + TState State { get; } + + /// + /// Gets the Robotics hosting context that owns the build. + /// + IRoboticsBuildContext BuildContext { get; } + + /// + /// Applies low-level state configuration before registration. + /// + IRoboticsNodeBuilder Configure(Action configure); + + /// + /// Resolves the registered state through the shared fluent node builder. + /// + /// + /// The containing motion-device system has not been registered. + /// + INodeBuilder AsNode(); + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/MotionBuilderInterfaces.cs b/src/Opc.Ua.Robotics.Server/Builders/MotionBuilderInterfaces.cs new file mode 100644 index 0000000000..3b7cbd2520 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/MotionBuilderInterfaces.cs @@ -0,0 +1,284 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Di.Server.Builders; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Builds a Robotics motion device. + /// + public interface IMotionDeviceBuilder : IRoboticsNodeBuilder + { + /// + /// Sets inherited DI identification fields. + /// + IMotionDeviceBuilder WithIdentification(Action configure); + + /// + /// Sets the required motion-device category. + /// + IMotionDeviceBuilder WithCategory(MotionDeviceCategoryEnumeration category); + + /// + /// Sets the required motion-device category. + /// + IMotionDeviceBuilder WithMotionDeviceCategory( + MotionDeviceCategoryEnumeration category); + + /// + /// Sets the optional component name. + /// + IMotionDeviceBuilder WithComponentName(string componentName); + + /// + /// Sets the optional localized component name. + /// + IMotionDeviceBuilder WithComponentName(LocalizedText componentName); + + /// + /// Adds an axis to the mandatory Axes folder. + /// + IAxisBuilder AddAxis(string browseName, Action? configure = null); + + /// + /// Adds an axis to the mandatory Axes folder. + /// + IAxisBuilder AddAxis(QualifiedName browseName, Action? configure = null); + + /// + /// Adds a power train to the mandatory PowerTrains folder. + /// + IPowerTrainBuilder AddPowerTrain( + string browseName, + Action? configure = null); + + /// + /// Adds a power train to the mandatory PowerTrains folder. + /// + IPowerTrainBuilder AddPowerTrain( + QualifiedName browseName, + Action? configure = null); + + /// + /// Materializes and returns the optional FlangeLoad. + /// + ILoadBuilder WithFlangeLoad(); + + /// + /// Materializes and configures the optional FlangeLoad. + /// + IMotionDeviceBuilder WithFlangeLoad(Action configure); + + /// + /// Adds an auxiliary component to AdditionalComponents. + /// + IAuxiliaryComponentBuilder AddAuxiliaryComponent( + string browseName, + Action? configure = null); + + /// + /// Adds an auxiliary component to AdditionalComponents. + /// + IAuxiliaryComponentBuilder AddAuxiliaryComponent( + QualifiedName browseName, + Action? configure = null); + + /// + /// Adds a drive to AdditionalComponents. + /// + IDriveBuilder AddDrive(string browseName, Action? configure = null); + + /// + /// Adds a drive to AdditionalComponents. + /// + IDriveBuilder AddDrive(QualifiedName browseName, Action? configure = null); + + /// + /// Sets the mandatory speed override value. + /// + IMotionDeviceBuilder WithSpeedOverride( + double value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Adds an application-owned, explicitly non-normative operations convention object. + /// + IRoboticsOperationsBuilder AddOperations( + string browseName, + ushort applicationNamespaceIndex, + Action configure); + + /// + /// Binds asynchronous reads for SpeedOverride. + /// + IMotionDeviceBuilder BindSpeedOverrideRead( + Func> read); + + /// + /// Binds asynchronous writes for SpeedOverride. + /// + IMotionDeviceBuilder BindSpeedOverrideWrite( + Func> write); + + /// + /// Binds asynchronous reads and writes for SpeedOverride. + /// + IMotionDeviceBuilder BindSpeedOverride( + Func> read, + Func> write); + + /// + /// Adds the standard Controls relationship from a task control. + /// + IMotionDeviceBuilder UsesTaskControl(ITaskControlBuilder taskControl); + + /// + /// Adds a symmetric Robotics IsConnectedTo relationship. + /// + /// The generated target state type. + IMotionDeviceBuilder IsConnectedTo(IRoboticsNodeBuilder other) + where TState : NodeState; + } + + /// + /// Builds a Robotics axis and its telemetry. + /// + public interface IAxisBuilder : IRoboticsNodeBuilder + { + /// + /// Sets the required motion profile. + /// + IAxisBuilder WithMotionProfile(AxisMotionProfileEnumeration profile); + + /// + /// Marks the axis as virtual for Requires-cardinality validation. + /// + IAxisBuilder AsVirtual(bool isVirtual = true); + + /// + /// Sets mandatory ActualPosition telemetry. + /// + IAxisBuilder WithActualPosition( + double value, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Materializes and sets optional ActualSpeed telemetry. + /// + IAxisBuilder WithActualSpeed( + double value, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Materializes and sets optional ActualAcceleration telemetry. + /// + IAxisBuilder WithActualAcceleration( + double value, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Binds asynchronous reads for ActualPosition. + /// + IAxisBuilder BindActualPosition( + Func> read); + + /// + /// Materializes ActualSpeed and binds asynchronous reads. + /// + IAxisBuilder BindActualSpeed(Func> read); + + /// + /// Materializes ActualAcceleration and binds asynchronous reads. + /// + IAxisBuilder BindActualAcceleration( + Func> read); + + /// + /// Materializes and returns the optional AdditionalLoad. + /// + ILoadBuilder WithAdditionalLoad(); + + /// + /// Materializes and configures the optional AdditionalLoad. + /// + IAxisBuilder WithAdditionalLoad(Action configure); + + /// + /// Adds the standard Requires relationship to a power train. + /// + IAxisBuilder Requires(IPowerTrainBuilder powerTrain); + + /// + /// Adds a symmetric Robotics IsConnectedTo relationship. + /// + /// The generated target state type. + IAxisBuilder IsConnectedTo(IRoboticsNodeBuilder other) + where TState : NodeState; + } + + /// + /// Builds the standard Robotics LoadType. + /// + public interface ILoadBuilder : IRoboticsNodeBuilder + { + /// + /// Sets the mandatory mass and optional engineering metadata. + /// + ILoadBuilder WithMass( + double mass, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Materializes and sets CenterOfMass. + /// + ILoadBuilder WithCenterOfMass(ThreeDFrame centerOfMass); + + /// + /// Materializes and sets Inertia. + /// + ILoadBuilder WithInertia(ThreeDVector inertia); + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/MotionBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/MotionBuilders.cs new file mode 100644 index 0000000000..98337df480 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/MotionBuilders.cs @@ -0,0 +1,676 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal sealed class MotionDeviceBuilder : + RoboticsNodeBuilder, + IMotionDeviceBuilder + { + private LoadBuilder? m_flangeLoad; + + public MotionDeviceBuilder(RoboticsBuildScope scope, MotionDeviceState state) + : base(scope, state) + { + scope.MotionDevices.Add(this); + } + + internal List Axes { get; } = []; + + internal List PowerTrains { get; } = []; + + public IMotionDeviceBuilder WithIdentification( + Action configure) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.ApplyIdentification( + State, + Scope.Context, + configure); + return this; + } + + public IMotionDeviceBuilder WithCategory( + MotionDeviceCategoryEnumeration category) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue(State.MotionDeviceCategory!, category); + return this; + } + + public IMotionDeviceBuilder WithMotionDeviceCategory( + MotionDeviceCategoryEnumeration category) + { + return WithCategory(category); + } + + public IMotionDeviceBuilder WithComponentName(string componentName) + { + return WithComponentName(new LocalizedText(componentName)); + } + + public IMotionDeviceBuilder WithComponentName(LocalizedText componentName) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetComponentName(State, Scope.Context, componentName); + return this; + } + + public IAxisBuilder AddAxis( + string browseName, + Action? configure = null) + { + return AddAxis( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IAxisBuilder AddAxis( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + FolderState parent = State.Axes ?? + throw MissingMandatoryContainer(BrowseNames.Axes); + AxisState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + parent, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfAxisType(container, name)); + var builder = new AxisBuilder(Scope, state); + Axes.Add(builder); + configure?.Invoke(builder); + return builder; + } + + public IPowerTrainBuilder AddPowerTrain( + string browseName, + Action? configure = null) + { + return AddPowerTrain( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IPowerTrainBuilder AddPowerTrain( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + FolderState parent = State.PowerTrains ?? + throw MissingMandatoryContainer(BrowseNames.PowerTrains); + PowerTrainState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + parent, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfPowerTrainType(container, name)); + var builder = new PowerTrainBuilder(Scope, state); + PowerTrains.Add(builder); + configure?.Invoke(builder); + return builder; + } + + public ILoadBuilder WithFlangeLoad() + { + Scope.EnsureMutable(); + State.AddFlangeLoad(Scope.Context); + return m_flangeLoad ??= new LoadBuilder(Scope, State.FlangeLoad!); + } + + public IMotionDeviceBuilder WithFlangeLoad(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + configure(WithFlangeLoad()); + return this; + } + + public IAuxiliaryComponentBuilder AddAuxiliaryComponent( + string browseName, + Action? configure = null) + { + return AddAuxiliaryComponent( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IAuxiliaryComponentBuilder AddAuxiliaryComponent( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + State.AddAdditionalComponents(Scope.Context); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + AuxiliaryComponentState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + State.AdditionalComponents!, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfAuxiliaryComponentType(container, name)); + var builder = new AuxiliaryComponentBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + public IDriveBuilder AddDrive( + string browseName, + Action? configure = null) + { + return AddDrive( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IDriveBuilder AddDrive( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + State.AddAdditionalComponents(Scope.Context); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + DriveState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + State.AdditionalComponents!, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfDriveType(container, name)); + var builder = new DriveBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + public IMotionDeviceBuilder WithSpeedOverride( + double value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + EnsureValidSpeedOverride(value, nameof(value)); + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + SpeedOverride, + value, + statusCode, + timestamp); + return this; + } + + public IRoboticsOperationsBuilder AddOperations( + string browseName, + ushort applicationNamespaceIndex, + Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + Scope.EnsureMutable(); + var builder = new RoboticsOperationsBuilder( + Scope, + State, + browseName, + applicationNamespaceIndex); + configure(builder); + return builder; + } + + public IMotionDeviceBuilder BindSpeedOverrideRead( + Func> read) + { + if (read == null) + { + throw new ArgumentNullException(nameof(read)); + } + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead( + SpeedOverride, + async cancellationToken => + { + DataValue value = await read(cancellationToken).ConfigureAwait(false); + return ValidateSpeedOverrideRead(value); + }); + return this; + } + + public IMotionDeviceBuilder BindSpeedOverrideWrite( + Func> write) + { + if (write == null) + { + throw new ArgumentNullException(nameof(write)); + } + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindWrite( + SpeedOverride, + (value, cancellationToken) => + { + ServiceResult validation = ValidateSpeedOverrideWrite(value); + return ServiceResult.IsGood(validation) + ? write(value, cancellationToken) + : new ValueTask(validation); + }); + return this; + } + + public IMotionDeviceBuilder BindSpeedOverride( + Func> read, + Func> write) + { + BindSpeedOverrideRead(read); + BindSpeedOverrideWrite(write); + return this; + } + + public IMotionDeviceBuilder UsesTaskControl(ITaskControlBuilder taskControl) + { + TaskControlBuilder target = + RequireSameScope( + taskControl, + nameof(taskControl)); + Scope.AddTaskControlRelation(target, this); + return this; + } + + public IMotionDeviceBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TState : NodeState + { + return IsConnectedTo(other); + } + + private BaseDataVariableState SpeedOverride + { + get + { + BaseObjectState parameterSet = State.ParameterSet ?? + throw MissingMandatoryContainer("ParameterSet"); + return RoboticsBuilderUtilities.FindRequiredChild< + BaseDataVariableState>( + Scope.Context, + parameterSet, + BrowseNames.SpeedOverride); + } + } + + internal void SetTaskControlReference( + TaskControlOperationState taskControlOperation) + { + if (taskControlOperation == null) + { + throw new ArgumentNullException(nameof(taskControlOperation)); + } + if (State.NodeId.IsNull || taskControlOperation.NodeId.IsNull) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "TaskControlReference can only be set after the motion device and " + + "TaskControlOperation have final NodeIds."); + } + + State.AddTaskControlReference(Scope.Context); + RoboticsBuilderUtilities.SetValue( + State.TaskControlReference!, + taskControlOperation.NodeId); + } + + private static DataValue ValidateSpeedOverrideRead(in DataValue value) + { + if (value.IsNull || StatusCode.IsBad(value.StatusCode)) + { + return value; + } + if (!value.WrappedValue.TryGetValue(out double speedOverride)) + { + return new DataValue( + value.WrappedValue, + StatusCodes.BadTypeMismatch, + value.SourceTimestamp, + value.ServerTimestamp); + } + if (!IsValidSpeedOverride(speedOverride)) + { + return new DataValue( + value.WrappedValue, + StatusCodes.BadOutOfRange, + value.SourceTimestamp, + value.ServerTimestamp); + } + return value; + } + + private static ServiceResult ValidateSpeedOverrideWrite(in Variant value) + { + if (!value.TryGetValue(out double speedOverride)) + { + return new ServiceResult(StatusCodes.BadTypeMismatch); + } + return IsValidSpeedOverride(speedOverride) + ? ServiceResult.Good + : new ServiceResult(StatusCodes.BadOutOfRange); + } + + private static void EnsureValidSpeedOverride(double value, string parameterName) + { + if (!IsValidSpeedOverride(value)) + { + throw new ArgumentOutOfRangeException( + parameterName, + value, + "SpeedOverride must be finite and in the inclusive range [0, 100]."); + } + } + + private static bool IsValidSpeedOverride(double value) + { + return !double.IsNaN(value) && + !double.IsInfinity(value) && + value >= 0 && + value <= 100; + } + + private ServiceResultException MissingMandatoryContainer(string browseName) + { + return ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory child '{0}' is missing below '{1}'.", + browseName, + State.BrowseName); + } + } + + internal sealed class AxisBuilder : + RoboticsNodeBuilder, + IAxisBuilder + { + private LoadBuilder? m_additionalLoad; + + public AxisBuilder(RoboticsBuildScope scope, AxisState state) + : base(scope, state) + { + scope.Axes.Add(this); + } + + internal bool IsVirtual { get; private set; } + + internal HashSet RequiredPowerTrains { get; } = []; + + public IAxisBuilder WithMotionProfile(AxisMotionProfileEnumeration profile) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue(State.MotionProfile!, profile); + return this; + } + + public IAxisBuilder AsVirtual(bool isVirtual = true) + { + Scope.EnsureMutable(); + IsVirtual = isVirtual; + return this; + } + + public IAxisBuilder WithActualPosition( + double value, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetAnalogValue( + ActualPosition, + Scope.Context, + value, + engineeringUnits, + range, + statusCode, + timestamp); + return this; + } + + public IAxisBuilder WithActualSpeed( + double value, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetAnalogValue( + EnsureActualSpeed(), + Scope.Context, + value, + engineeringUnits, + range, + statusCode, + timestamp); + return this; + } + + public IAxisBuilder WithActualAcceleration( + double value, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetAnalogValue( + EnsureActualAcceleration(), + Scope.Context, + value, + engineeringUnits, + range, + statusCode, + timestamp); + return this; + } + + public IAxisBuilder BindActualPosition( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(ActualPosition, read); + return this; + } + + public IAxisBuilder BindActualSpeed( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(EnsureActualSpeed(), read); + return this; + } + + public IAxisBuilder BindActualAcceleration( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(EnsureActualAcceleration(), read); + return this; + } + + public ILoadBuilder WithAdditionalLoad() + { + Scope.EnsureMutable(); + State.AddAdditionalLoad(Scope.Context); + return m_additionalLoad ??= new LoadBuilder(Scope, State.AdditionalLoad!); + } + + public IAxisBuilder WithAdditionalLoad(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + configure(WithAdditionalLoad()); + return this; + } + + public IAxisBuilder Requires(IPowerTrainBuilder powerTrain) + { + PowerTrainBuilder target = + RequireSameScope( + powerTrain, + nameof(powerTrain)); + RequiredPowerTrains.Add(target); + Scope.AddSemanticReference( + RoboticsSemanticReference.Requires, + this, + target); + return this; + } + + public IAxisBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TState : NodeState + { + return IsConnectedTo(other); + } + + private BaseObjectState ParameterSet => State.ParameterSet ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory ParameterSet is missing below axis '{0}'.", + State.BrowseName); + + private AnalogUnitState ActualPosition => + RoboticsBuilderUtilities.FindRequiredChild>( + Scope.Context, + ParameterSet, + BrowseNames.ActualPosition); + + private AnalogUnitState EnsureActualSpeed() + { + AnalogUnitState? variable = RoboticsBuilderUtilities.FindChild>( + Scope.Context, + ParameterSet, + BrowseNames.ActualSpeed); + if (variable == null) + { + variable = RoboticsBuilderUtilities.AddGeneratedChild( + Scope.Context, + ParameterSet, + parent => + OpcUaRoboticsExtensions.CreateAxisType_ParameterSet_ActualSpeed( + Scope.Context, + parent, + true)); + } + return variable; + } + + private AnalogUnitState EnsureActualAcceleration() + { + AnalogUnitState? variable = RoboticsBuilderUtilities.FindChild>( + Scope.Context, + ParameterSet, + BrowseNames.ActualAcceleration); + if (variable == null) + { + variable = RoboticsBuilderUtilities.AddGeneratedChild( + Scope.Context, + ParameterSet, + parent => + OpcUaRoboticsExtensions + .CreateAxisType_ParameterSet_ActualAcceleration( + Scope.Context, + parent, + true)); + } + return variable; + } + } + + internal sealed class LoadBuilder : + RoboticsNodeBuilder, + ILoadBuilder + { + public LoadBuilder(RoboticsBuildScope scope, LoadState state) + : base(scope, state) + { + } + + public ILoadBuilder WithMass( + double mass, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetAnalogValue( + State.Mass!, + Scope.Context, + mass, + engineeringUnits, + range, + statusCode, + timestamp); + return this; + } + + public ILoadBuilder WithCenterOfMass(ThreeDFrame centerOfMass) + { + if (centerOfMass == null) + { + throw new ArgumentNullException(nameof(centerOfMass)); + } + Scope.EnsureMutable(); + State.AddCenterOfMass(Scope.Context); + RoboticsBuilderUtilities.SetValue(State.CenterOfMass!, centerOfMass); + return this; + } + + public ILoadBuilder WithInertia(ThreeDVector inertia) + { + if (inertia == null) + { + throw new ArgumentNullException(nameof(inertia)); + } + Scope.EnsureMutable(); + State.AddInertia(Scope.Context); + RoboticsBuilderUtilities.SetValue(State.Inertia!, inertia); + return this; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/OperationBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/OperationBuilders.cs new file mode 100644 index 0000000000..d336131a6a --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/OperationBuilders.cs @@ -0,0 +1,595 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server.StateMachines; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal sealed class RoboticsUserBuilder : + RoboticsNodeBuilder, + IRoboticsUserBuilder + { + public RoboticsUserBuilder(RoboticsBuildScope scope, UserState state) + : base(scope, state) + { + } + + public IRoboticsUserBuilder WithLevel(string level) + { + if (level == null) + { + throw new ArgumentNullException(nameof(level)); + } + + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue(State.Level!, level); + return this; + } + + public IRoboticsUserBuilder WithName(string name) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + Scope.EnsureMutable(); + State.AddName(Scope.Context); + RoboticsBuilderUtilities.SetValue(State.Name!, name); + return this; + } + } + + internal abstract class RoboticsOperationBuilder : + RoboticsNodeBuilder + where TState : BaseObjectState + where TMachine : OperationStateMachineState + { + private readonly FiniteStateMachineDispatcher m_dispatcher; + + /// + /// Serialises transitions on this operation: 0 when idle, 1 while a + /// transition is in flight. The guard, the application handler and the + /// commit have to be one atomic step, because the handler may run for + /// as long as the physical motion it starts. Without this a second + /// caller passes the same guard and a robot can be commanded to start + /// and stand down at once, leaving the published CurrentState + /// describing whichever commit happened to land last. + /// + private int m_transitionInFlight; + + private Func? m_transition; + private short m_transitionReason; + + protected RoboticsOperationBuilder( + RoboticsBuildScope scope, + TState state, + TMachine machine, + ArrayOf states, + ArrayOf transitions) + : base(scope, state) + { + Machine = machine; + m_dispatcher = new FiniteStateMachineDispatcher( + (ushort)scope.Context.NamespaceUris.GetIndex(Namespaces.Robotics), + states, + transitions); + m_dispatcher.InitializeToInitialState( + Machine, + StateId(RoboticsOperationState.Idle), + scope.Context); + SetLastTransitionReason(); + } + + internal TMachine Machine { get; } + + protected void SetInitialState(RoboticsOperationState state) + { + Scope.EnsureMutable(); + m_dispatcher.InitializeToInitialState(Machine, StateId(state), Scope.Context); + SetLastTransitionReason(); + } + + protected void SetTransitionHandler( + Func handler) + { + m_transition = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + protected void SetTransitionReason(short reason) + { + Scope.EnsureMutable(); + m_transitionReason = reason; + SetLastTransitionReason(); + } + + protected RoboticsOperationContext CreateContext(RoboticsOperationState currentState) + { + return new RoboticsOperationContext + { + OperationNodeId = State.NodeId, + StateMachineNodeId = Machine.NodeId, + CurrentState = currentState + }; + } + + protected async ValueTask InvokeOperationAsync( + RoboticsOperationState fromState, + RoboticsOperationState toState, + uint transitionId, + Func> handler, + CancellationToken cancellationToken) + { + if (!TryBeginTransition()) + { + return new ServiceResult(StatusCodes.BadInvalidState); + } + + try + { + if (!IsCurrentState(fromState)) + { + return new ServiceResult(StatusCodes.BadInvalidState); + } + + ServiceResult result = await handler(CreateContext(fromState), cancellationToken) + .ConfigureAwait(false); + result ??= ServiceResult.Good; + if (ServiceResult.IsBad(result)) + { + return result; + } + + await MoveAsync(fromState, toState, transitionId, cancellationToken) + .ConfigureAwait(false); + return result; + } + finally + { + EndTransition(); + } + } + + protected async ValueTask InvokeStopAsync( + long stopMode, + Func> handler, + CancellationToken cancellationToken) + { + if (!TryBeginTransition()) + { + return new ServiceResult(StatusCodes.BadInvalidState); + } + + try + { + if (!IsCurrentState(RoboticsOperationState.Executing)) + { + return new ServiceResult(StatusCodes.BadInvalidState); + } + + var request = new RoboticsStopRequest + { + Context = CreateContext(RoboticsOperationState.Executing), + StopMode = (RoboticsStopMode)(short)stopMode + }; + ServiceResult result = await handler(request, cancellationToken).ConfigureAwait(false); + result ??= ServiceResult.Good; + if (ServiceResult.IsBad(result)) + { + return result; + } + + await MoveAsync( + RoboticsOperationState.Executing, + RoboticsOperationState.Ready, + TransitionId(RoboticsOperationState.Executing, RoboticsOperationState.Ready), + cancellationToken).ConfigureAwait(false); + return result; + } + finally + { + EndTransition(); + } + } + + protected async ValueTask InvokeProgramAsync( + RoboticsOperationState fromState, + RoboticsOperationState toState, + uint transitionId, + Func> handler, + CancellationToken cancellationToken) + { + if (!TryBeginTransition()) + { + return BadInvalidStateProgramResult(); + } + + try + { + if (!IsCurrentState(fromState)) + { + return BadInvalidStateProgramResult(); + } + + RoboticsProgramResult result = await handler(cancellationToken).ConfigureAwait(false); + if (ServiceResult.IsBad(result.ServiceResult)) + { + return result; + } + + await MoveAsync(fromState, toState, transitionId, cancellationToken) + .ConfigureAwait(false); + return result; + } + finally + { + EndTransition(); + } + } + + /// + /// Claims the operation for one transition, without waiting. + /// + /// + /// A caller that arrives while a transition is in flight is rejected + /// rather than queued: these methods actuate a physical machine, so + /// blocking would let a command land long after the operator issued it. + /// + /// + /// true when the caller owns the transition and must call + /// . + /// + private bool TryBeginTransition() + { + return Interlocked.CompareExchange(ref m_transitionInFlight, 1, 0) == 0; + } + + private void EndTransition() + { + Interlocked.Exchange(ref m_transitionInFlight, 0); + } + + protected RoboticsProgramResult BadInvalidStateProgramResult() + { + return new RoboticsProgramResult + { + ServiceResult = new ServiceResult(StatusCodes.BadInvalidState) + }; + } + + protected static int StatusFrom(ServiceResult result) + { + return ServiceResult.IsGood(result) ? 0 : (int)result.StatusCode.Code; + } + + protected abstract uint StateId(RoboticsOperationState state); + + protected abstract RoboticsOperationState StateFromId(uint stateId); + + protected abstract uint TransitionId( + RoboticsOperationState fromState, + RoboticsOperationState toState); + + private bool IsCurrentState(RoboticsOperationState state) + { + return m_dispatcher.TryGetCurrentState(Machine, out uint stateId) && + StateFromId(stateId) == state; + } + + private async ValueTask MoveAsync( + RoboticsOperationState fromState, + RoboticsOperationState toState, + uint transitionId, + CancellationToken cancellationToken) + { + m_dispatcher.Move(Machine, StateId(toState), transitionId, Scope.Context); + SetLastTransitionReason(); + if (m_transition != null) + { + await m_transition( + new RoboticsOperationTransition + { + FromState = fromState, + ToState = toState, + TransitionId = new NodeId( + transitionId, + (ushort)Scope.Context.NamespaceUris.GetIndex(Namespaces.Robotics)), + Reason = m_transitionReason + }, + cancellationToken).ConfigureAwait(false); + } + } + + private void SetLastTransitionReason() + { + if (Machine.LastTransitionReason is null) + { + return; + } + + Machine.LastTransitionReason.Value = m_transitionReason; + Machine.LastTransitionReason.ClearChangeMasks(Scope.Context, includeChildren: true); + } + } + + internal sealed partial class SystemOperationBuilder : + RoboticsOperationBuilder, + ISystemOperationBuilder + { + private static readonly ArrayOf s_states = + [ + new( + SystemOperationStateMachineTypeIds.StateIds.Idle, + SystemOperationStateMachineTypeIds.StateNumbers.Idle, + "Idle"), + new( + SystemOperationStateMachineTypeIds.StateIds.Ready, + SystemOperationStateMachineTypeIds.StateNumbers.Ready, + "Ready"), + new( + SystemOperationStateMachineTypeIds.StateIds.Executing, + SystemOperationStateMachineTypeIds.StateNumbers.Executing, + "Executing") + ]; + + private static readonly ArrayOf s_transitions = + [ + new( + SystemOperationStateMachineTypeIds.TransitionIds.IdleToIdle, + SystemOperationStateMachineTypeIds.TransitionNumbers.IdleToIdle, + "IdleToIdle"), + new( + SystemOperationStateMachineTypeIds.TransitionIds.IdleToReady, + SystemOperationStateMachineTypeIds.TransitionNumbers.IdleToReady, + "IdleToReady"), + new( + SystemOperationStateMachineTypeIds.TransitionIds.ReadyToIdle, + SystemOperationStateMachineTypeIds.TransitionNumbers.ReadyToIdle, + "ReadyToIdle"), + new( + SystemOperationStateMachineTypeIds.TransitionIds.ReadyToExecuting, + SystemOperationStateMachineTypeIds.TransitionNumbers.ReadyToExecuting, + "ReadyToExecuting"), + new( + SystemOperationStateMachineTypeIds.TransitionIds.ExecutingToReady, + SystemOperationStateMachineTypeIds.TransitionNumbers.ExecutingToReady, + "ExecutingToReady"), + new( + SystemOperationStateMachineTypeIds.TransitionIds.ExecutingToIdle, + SystemOperationStateMachineTypeIds.TransitionNumbers.ExecutingToIdle, + "ExecutingToIdle") + ]; + + public SystemOperationBuilder(RoboticsBuildScope scope, SystemOperationState state) + : base( + scope, + state, + state.SystemOperationStateMachine ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated SystemOperationStateMachine is missing below '{0}'.", + state.BrowseName), + s_states, + s_transitions) + { + } + + public ISystemOperationBuilder WithInitialState(RoboticsOperationState state) + { + SetInitialState(state); + return this; + } + + public ISystemOperationBuilder OnGetReady( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddGetReady(Scope.Context); + Machine.GetReady!.OnCallAsync = async (_, _, _, cancellationToken) => + { + ServiceResult result = await InvokeOperationAsync( + RoboticsOperationState.Idle, + RoboticsOperationState.Ready, + SystemOperationStateMachineTypeIds.TransitionIds.IdleToReady, + handler, + cancellationToken).ConfigureAwait(false); + return new GetReadyMethodStateResult + { + ServiceResult = result, + Status = StatusFrom(result) + }; + }; + return this; + } + + public ISystemOperationBuilder OnStart( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddStart(Scope.Context); + Machine.Start!.OnCallAsync = async (_, _, _, cancellationToken) => + { + ServiceResult result = await InvokeOperationAsync( + RoboticsOperationState.Ready, + RoboticsOperationState.Executing, + SystemOperationStateMachineTypeIds.TransitionIds.ReadyToExecuting, + handler, + cancellationToken).ConfigureAwait(false); + return new StartMethodStateResult + { + ServiceResult = result, + Status = StatusFrom(result) + }; + }; + return this; + } + + public ISystemOperationBuilder OnStop( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddStop(Scope.Context); + Machine.Stop!.OnCallAsync = async (_, _, _, stopMode, cancellationToken) => + { + ServiceResult result = await InvokeStopAsync(stopMode, handler, cancellationToken) + .ConfigureAwait(false); + return new StopMethodStateResult + { + ServiceResult = result, + Status = StatusFrom(result) + }; + }; + return this; + } + + public ISystemOperationBuilder OnStandDown( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddStandDown(Scope.Context); + Machine.StandDown!.OnCallAsync = async (_, _, _, cancellationToken) => + { + ServiceResult result = await InvokeOperationAsync( + RoboticsOperationState.Ready, + RoboticsOperationState.Idle, + SystemOperationStateMachineTypeIds.TransitionIds.ReadyToIdle, + handler, + cancellationToken).ConfigureAwait(false); + return new StandDownMethodStateResult + { + ServiceResult = result, + Status = StatusFrom(result) + }; + }; + return this; + } + + public ISystemOperationBuilder WithStopModes( + ArrayOf modes, + RoboticsStopMode defaultMode) + { + if (!modes.Contains(defaultMode)) + { + throw new ArgumentException( + "The default stop mode must be included in the possible stop modes.", + nameof(defaultMode)); + } + + Scope.EnsureMutable(); + Machine.AddPossibleStopModes(Scope.Context); + Machine.AddConfiguredDefaultStopMode(Scope.Context); + var values = new EnumValueType[modes.Count]; + for (int ii = 0; ii < modes.Count; ii++) + { + RoboticsStopMode mode = modes[ii]; + values[ii] = new EnumValueType + { + Value = (short)mode, + DisplayName = new LocalizedText(mode.ToString()) + }; + } + + RoboticsBuilderUtilities.SetValue(Machine.PossibleStopModes!, new ArrayOf(values)); + RoboticsBuilderUtilities.SetValue(Machine.ConfiguredDefaultStopMode!, (short)defaultMode); + return this; + } + + public ISystemOperationBuilder OnTransition( + Func handler) + { + SetTransitionHandler(handler); + return this; + } + + public ISystemOperationBuilder WithTransitionReason(short reason) + { + SetTransitionReason(reason); + return this; + } + + protected override uint StateId(RoboticsOperationState state) + { + return state switch + { + RoboticsOperationState.Idle => SystemOperationStateMachineTypeIds.StateIds.Idle, + RoboticsOperationState.Ready => SystemOperationStateMachineTypeIds.StateIds.Ready, + RoboticsOperationState.Executing => SystemOperationStateMachineTypeIds.StateIds.Executing, + _ => throw new ArgumentOutOfRangeException(nameof(state)) + }; + } + + protected override RoboticsOperationState StateFromId(uint stateId) + { + return stateId switch + { + SystemOperationStateMachineTypeIds.StateIds.Idle => RoboticsOperationState.Idle, + SystemOperationStateMachineTypeIds.StateIds.Ready => RoboticsOperationState.Ready, + SystemOperationStateMachineTypeIds.StateIds.Executing => RoboticsOperationState.Executing, + _ => throw new ArgumentOutOfRangeException(nameof(stateId)) + }; + } + + protected override uint TransitionId( + RoboticsOperationState fromState, + RoboticsOperationState toState) + { + return (fromState, toState) switch + { + (RoboticsOperationState.Ready, RoboticsOperationState.Executing) => + SystemOperationStateMachineTypeIds.TransitionIds.ReadyToExecuting, + (RoboticsOperationState.Executing, RoboticsOperationState.Ready) => + SystemOperationStateMachineTypeIds.TransitionIds.ExecutingToReady, + _ => throw new ArgumentOutOfRangeException(nameof(toState)) + }; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/PowerTrainBuilderInterfaces.cs b/src/Opc.Ua.Robotics.Server/Builders/PowerTrainBuilderInterfaces.cs new file mode 100644 index 0000000000..b2fb4801f5 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/PowerTrainBuilderInterfaces.cs @@ -0,0 +1,255 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Di.Server.Builders; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Builds a Robotics power train. + /// + public interface IPowerTrainBuilder : IRoboticsNodeBuilder + { + /// + /// Sets the optional component name. + /// + IPowerTrainBuilder WithComponentName(string componentName); + + /// + /// Sets the optional localized component name. + /// + IPowerTrainBuilder WithComponentName(LocalizedText componentName); + + /// + /// Adds a contained motor. + /// + IMotorBuilder AddMotor(string browseName, Action? configure = null); + + /// + /// Adds a contained motor. + /// + IMotorBuilder AddMotor(QualifiedName browseName, Action? configure = null); + + /// + /// Adds an optional contained gear. + /// + IGearBuilder AddGear(string browseName, Action? configure = null); + + /// + /// Adds an optional contained gear. + /// + IGearBuilder AddGear(QualifiedName browseName, Action? configure = null); + + /// + /// Adds the standard Moves relationship. + /// + IPowerTrainBuilder Moves(IAxisBuilder axis); + + /// + /// Adds the standard HasSlave relationship. + /// + IPowerTrainBuilder HasSlave(IPowerTrainBuilder slave); + + /// + /// Adds a symmetric Robotics IsConnectedTo relationship. + /// + /// The generated target state type. + IPowerTrainBuilder IsConnectedTo(IRoboticsNodeBuilder other) + where TState : NodeState; + } + + /// + /// Builds a Robotics motor. + /// + public interface IMotorBuilder : IRoboticsNodeBuilder + { + /// + /// Sets inherited DI identification fields. + /// + IMotorBuilder WithIdentification(Action configure); + + /// + /// Sets mandatory motor temperature telemetry. + /// + IMotorBuilder WithMotorTemperature( + double value, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Materializes and sets the optional brake-released telemetry. + /// + IMotorBuilder WithBrakeReleased( + bool value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Materializes and sets the optional effective-load-rate telemetry. + /// + IMotorBuilder WithEffectiveLoadRate( + ushort value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Binds asynchronous motor-temperature reads. + /// + IMotorBuilder BindMotorTemperature( + Func> read); + + /// + /// Materializes BrakeReleased and binds asynchronous reads. + /// + IMotorBuilder BindBrakeReleased(Func> read); + + /// + /// Materializes EffectiveLoadRate and binds asynchronous reads. + /// + IMotorBuilder BindEffectiveLoadRate( + Func> read); + + /// + /// Adds the optional standard IsDrivenBy relationship. + /// + IMotorBuilder IsDrivenBy(IDriveBuilder drive); + + /// + /// Adds a symmetric Robotics IsConnectedTo relationship. + /// + /// The generated target state type. + IMotorBuilder IsConnectedTo(IRoboticsNodeBuilder other) + where TState : NodeState; + } + + /// + /// Builds a Robotics gear. + /// + public interface IGearBuilder : IRoboticsNodeBuilder + { + /// + /// Sets inherited DI identification fields. + /// + IGearBuilder WithIdentification(Action configure); + + /// + /// Sets the required transmission ratio. + /// + IGearBuilder WithGearRatio(int numerator, uint denominator); + + /// + /// Materializes and sets optional pitch in millimetres per output-side + /// revolution. + /// + IGearBuilder WithPitch( + double millimetresPerRevolution, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Adds a symmetric Robotics IsConnectedTo relationship. + /// + /// The generated target state type. + IGearBuilder IsConnectedTo(IRoboticsNodeBuilder other) + where TState : NodeState; + } + + /// + /// Builds a Robotics drive. + /// + public interface IDriveBuilder : IRoboticsNodeBuilder + { + /// + /// Sets the mandatory product code. + /// + IDriveBuilder WithProductCode(string productCode); + + /// + /// Sets the optional asset identifier. + /// + IDriveBuilder WithAssetId(string assetId); + + /// + /// Sets the optional component name. + /// + IDriveBuilder WithComponentName(string componentName); + + /// + /// Sets the optional localized component name. + /// + IDriveBuilder WithComponentName(LocalizedText componentName); + + /// + /// Adds a symmetric Robotics IsConnectedTo relationship. + /// + /// The generated target state type. + IDriveBuilder IsConnectedTo(IRoboticsNodeBuilder other) + where TState : NodeState; + } + + /// + /// Builds an AuxiliaryComponentType instance. + /// + public interface IAuxiliaryComponentBuilder : + IRoboticsNodeBuilder + { + /// + /// Sets the mandatory product code. + /// + IAuxiliaryComponentBuilder WithProductCode(string productCode); + + /// + /// Sets the optional asset identifier. + /// + IAuxiliaryComponentBuilder WithAssetId(string assetId); + + /// + /// Sets the optional component name. + /// + IAuxiliaryComponentBuilder WithComponentName(string componentName); + + /// + /// Sets the optional localized component name. + /// + IAuxiliaryComponentBuilder WithComponentName(LocalizedText componentName); + + /// + /// Adds a symmetric Robotics IsConnectedTo relationship. + /// + /// The generated target state type. + IAuxiliaryComponentBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TState : NodeState; + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/PowerTrainBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/PowerTrainBuilders.cs new file mode 100644 index 0000000000..a0b86fbb99 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/PowerTrainBuilders.cs @@ -0,0 +1,490 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal sealed class PowerTrainBuilder : + RoboticsNodeBuilder, + IPowerTrainBuilder + { + public PowerTrainBuilder(RoboticsBuildScope scope, PowerTrainState state) + : base(scope, state) + { + scope.PowerTrains.Add(this); + } + + internal List Motors { get; } = []; + + public IPowerTrainBuilder WithComponentName(string componentName) + { + return WithComponentName(new LocalizedText(componentName)); + } + + public IPowerTrainBuilder WithComponentName(LocalizedText componentName) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetComponentName(State, Scope.Context, componentName); + return this; + } + + public IMotorBuilder AddMotor( + string browseName, + Action? configure = null) + { + return AddMotor( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IMotorBuilder AddMotor( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + MotorState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + State, + normalized, + (parent, name) => + Scope.Context.CreateInstanceOfMotorType(parent, name)); + var builder = new MotorBuilder(Scope, state); + Motors.Add(builder); + configure?.Invoke(builder); + return builder; + } + + public IGearBuilder AddGear( + string browseName, + Action? configure = null) + { + return AddGear( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IGearBuilder AddGear( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + GearState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + State, + normalized, + (parent, name) => + Scope.Context.CreateInstanceOfGearType(parent, name)); + var builder = new GearBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + public IPowerTrainBuilder Moves(IAxisBuilder axis) + { + AxisBuilder target = RequireSameScope( + axis, + nameof(axis)); + Scope.AddSemanticReference( + RoboticsSemanticReference.Moves, + this, + target); + return this; + } + + public IPowerTrainBuilder HasSlave(IPowerTrainBuilder slave) + { + PowerTrainBuilder target = + RequireSameScope( + slave, + nameof(slave)); + Scope.AddSemanticReference( + RoboticsSemanticReference.HasSlave, + this, + target); + return this; + } + + public IPowerTrainBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TState : NodeState + { + return IsConnectedTo(other); + } + } + + internal sealed class MotorBuilder : + RoboticsNodeBuilder, + IMotorBuilder + { + public MotorBuilder(RoboticsBuildScope scope, MotorState state) + : base(scope, state) + { + } + + public IMotorBuilder WithIdentification( + Action configure) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.ApplyIdentification( + State, + Scope.Context, + configure); + return this; + } + + public IMotorBuilder WithMotorTemperature( + double value, + EUInformation? engineeringUnits = null, + global::Opc.Ua.Range? range = null, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetAnalogValue( + MotorTemperature, + Scope.Context, + value, + engineeringUnits, + range, + statusCode, + timestamp); + return this; + } + + public IMotorBuilder WithBrakeReleased( + bool value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + EnsureBrakeReleased(), + value, + statusCode, + timestamp); + return this; + } + + public IMotorBuilder WithEffectiveLoadRate( + ushort value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + EnsureEffectiveLoadRate(), + value, + statusCode, + timestamp); + return this; + } + + public IMotorBuilder BindMotorTemperature( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(MotorTemperature, read); + return this; + } + + public IMotorBuilder BindBrakeReleased( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(EnsureBrakeReleased(), read); + return this; + } + + public IMotorBuilder BindEffectiveLoadRate( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(EnsureEffectiveLoadRate(), read); + return this; + } + + public IMotorBuilder IsDrivenBy(IDriveBuilder drive) + { + DriveBuilder target = RequireSameScope( + drive, + nameof(drive)); + Scope.AddSemanticReference( + RoboticsSemanticReference.IsDrivenBy, + this, + target); + return this; + } + + public IMotorBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TState : NodeState + { + return IsConnectedTo(other); + } + + private BaseObjectState ParameterSet => State.ParameterSet ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory ParameterSet is missing below motor '{0}'.", + State.BrowseName); + + private AnalogUnitState MotorTemperature => + RoboticsBuilderUtilities.FindRequiredChild>( + Scope.Context, + ParameterSet, + BrowseNames.MotorTemperature); + + private BaseDataVariableState EnsureBrakeReleased() + { + BaseDataVariableState? variable = RoboticsBuilderUtilities.FindChild>( + Scope.Context, + ParameterSet, + BrowseNames.BrakeReleased); + if (variable == null) + { + variable = RoboticsBuilderUtilities.AddGeneratedChild( + Scope.Context, + ParameterSet, + parent => + OpcUaRoboticsExtensions.CreateMotorType_ParameterSet_BrakeReleased( + Scope.Context, + parent, + true)); + } + return variable; + } + + private BaseDataVariableState EnsureEffectiveLoadRate() + { + BaseDataVariableState? variable = RoboticsBuilderUtilities.FindChild>( + Scope.Context, + ParameterSet, + BrowseNames.EffectiveLoadRate); + if (variable == null) + { + variable = RoboticsBuilderUtilities.AddGeneratedChild( + Scope.Context, + ParameterSet, + parent => + OpcUaRoboticsExtensions + .CreateMotorType_ParameterSet_EffectiveLoadRate( + Scope.Context, + parent, + true)); + } + return variable; + } + } + + internal sealed class GearBuilder : + RoboticsNodeBuilder, + IGearBuilder + { + public GearBuilder(RoboticsBuildScope scope, GearState state) + : base(scope, state) + { + scope.Gears.Add(this); + } + + internal bool HasGearRatio { get; private set; } + + public IGearBuilder WithIdentification( + Action configure) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.ApplyIdentification( + State, + Scope.Context, + configure); + return this; + } + + public IGearBuilder WithGearRatio(int numerator, uint denominator) + { + if (denominator == 0) + { + throw new ArgumentOutOfRangeException( + nameof(denominator), + denominator, + "The gear-ratio denominator must not be zero."); + } + + Scope.EnsureMutable(); + RationalNumberState ratio = State.GearRatio ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory GearRatio is missing below '{0}'.", + State.BrowseName); + var value = new RationalNumber + { + Numerator = numerator, + Denominator = denominator + }; + RoboticsBuilderUtilities.SetValue(ratio, value); + RoboticsBuilderUtilities.SetValue(ratio.Numerator!, value.Numerator); + RoboticsBuilderUtilities.SetValue(ratio.Denominator!, value.Denominator); + HasGearRatio = true; + return this; + } + + public IGearBuilder WithPitch( + double millimetresPerRevolution, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + if (double.IsNaN(millimetresPerRevolution) || + double.IsInfinity(millimetresPerRevolution)) + { + throw new ArgumentOutOfRangeException( + nameof(millimetresPerRevolution), + millimetresPerRevolution, + "Pitch must be a finite number of millimetres per output-side revolution."); + } + + Scope.EnsureMutable(); + State.AddPitch(Scope.Context); + RoboticsBuilderUtilities.SetValue( + State.Pitch!, + millimetresPerRevolution, + statusCode, + timestamp); + return this; + } + + public IGearBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TState : NodeState + { + return IsConnectedTo(other); + } + } + + internal sealed class DriveBuilder : + RoboticsNodeBuilder, + IDriveBuilder + { + public DriveBuilder(RoboticsBuildScope scope, DriveState state) + : base(scope, state) + { + } + + public IDriveBuilder WithProductCode(string productCode) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetProductCode( + State, + Scope.Context, + productCode); + return this; + } + + public IDriveBuilder WithAssetId(string assetId) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetAssetId(State, Scope.Context, assetId); + return this; + } + + public IDriveBuilder WithComponentName(string componentName) + { + return WithComponentName(new LocalizedText(componentName)); + } + + public IDriveBuilder WithComponentName(LocalizedText componentName) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetComponentName(State, Scope.Context, componentName); + return this; + } + + public IDriveBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TState : NodeState + { + return IsConnectedTo(other); + } + } + + internal sealed class AuxiliaryComponentBuilder : + RoboticsNodeBuilder, + IAuxiliaryComponentBuilder + { + public AuxiliaryComponentBuilder( + RoboticsBuildScope scope, + AuxiliaryComponentState state) + : base(scope, state) + { + } + + public IAuxiliaryComponentBuilder WithProductCode(string productCode) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetProductCode( + State, + Scope.Context, + productCode); + return this; + } + + public IAuxiliaryComponentBuilder WithAssetId(string assetId) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetAssetId(State, Scope.Context, assetId); + return this; + } + + public IAuxiliaryComponentBuilder WithComponentName(string componentName) + { + return WithComponentName(new LocalizedText(componentName)); + } + + public IAuxiliaryComponentBuilder WithComponentName( + LocalizedText componentName) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetComponentName(State, Scope.Context, componentName); + return this; + } + + public IAuxiliaryComponentBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TState : NodeState + { + return IsConnectedTo(other); + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/ProgramsBuilder.cs b/src/Opc.Ua.Robotics.Server/Builders/ProgramsBuilder.cs new file mode 100644 index 0000000000..add511d6f0 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/ProgramsBuilder.cs @@ -0,0 +1,123 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server.FileSystem; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Default implementation. Binding runs + /// after the completed Robotics tree is registered with the node manager, + /// so the materialised program files are added to a live address space. + /// + internal sealed class ProgramsBuilder : IProgramsBuilder + { + public ProgramsBuilder(RoboticsBuildScope scope, FileDirectoryState directory) + { + m_scope = scope ?? throw new ArgumentNullException(nameof(scope)); + Directory = directory ?? throw new ArgumentNullException(nameof(directory)); + } + + /// + public FileDirectoryState Directory { get; } + + /// + public IProgramsBuilder UseFileSystem(IFileSystemProvider provider) + { + if (provider == null) + { + throw new ArgumentNullException(nameof(provider)); + } + m_providerFactory = _ => provider; + return this; + } + + /// + public IProgramsBuilder UseFileSystem() + where TProvider : class, IFileSystemProvider + { + m_providerFactory = static context => context.GetRequiredService(); + return this; + } + + /// + public IProgramsBuilder WithOptions(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + configure(m_options); + return this; + } + + /// + /// Validates the configuration and schedules the binding to run once the + /// Robotics tree has been registered. + /// + /// + /// No file-system provider was configured. + /// + internal void Schedule() + { + if (m_providerFactory == null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Controller Programs requires a file system: call " + + "IProgramsBuilder.UseFileSystem before completing the build."); + } + m_scope.PostRegistrationActions.Add(BindAsync); + } + + private async ValueTask BindAsync(CancellationToken cancellationToken) + { + IRoboticsBuildContext context = m_scope.BuildContext; + IFileSystemProvider provider = m_providerFactory!(context); + IFileDirectoryBinder binder = context.GetRequiredService(); + + IFileDirectoryBinding binding = await binder.BindAsync( + Directory, + provider, + context.Context, + m_options, + (node, ct) => context.Manager.AddPredefinedNodeAsync(node, ct), + cancellationToken).ConfigureAwait(false); + + m_scope.RegisteredResources.Add(binding); + } + + private readonly RoboticsBuildScope m_scope; + private readonly FileDirectoryBindingOptions m_options = new(); + private Func? m_providerFactory; + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/RoboticsBuildContextExtensions.cs b/src/Opc.Ua.Robotics.Server/Builders/RoboticsBuildContextExtensions.cs new file mode 100644 index 0000000000..8dba54b697 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/RoboticsBuildContextExtensions.cs @@ -0,0 +1,143 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Entry points for progressively building Robotics topology instances. + /// + public static class RoboticsBuildContextExtensions + { + /// + /// Configures, validates, assigns and asynchronously registers one + /// MotionDeviceSystem below the DI DeviceSet. + /// + public static async ValueTask + AddMotionDeviceSystemAsync( + this IRoboticsBuildContext buildContext, + string browseName, + Action configure, + CancellationToken cancellationToken = default) + { + if (buildContext == null) + { + throw new ArgumentNullException(nameof(buildContext)); + } + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + using IDisposable lease = RoboticsBuildScope.AcquireBuildLease(buildContext); + return await AddMotionDeviceSystemCoreAsync( + buildContext, + RoboticsBuilderUtilities.NormalizeBrowseName(buildContext, browseName), + configure, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Configures, validates, assigns and asynchronously registers one + /// MotionDeviceSystem below the DI DeviceSet. + /// + public static async ValueTask + AddMotionDeviceSystemAsync( + this IRoboticsBuildContext buildContext, + QualifiedName browseName, + Action configure, + CancellationToken cancellationToken = default) + { + if (buildContext == null) + { + throw new ArgumentNullException(nameof(buildContext)); + } + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + using IDisposable lease = RoboticsBuildScope.AcquireBuildLease(buildContext); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(buildContext, browseName); + return await AddMotionDeviceSystemCoreAsync( + buildContext, + normalized, + configure, + cancellationToken).ConfigureAwait(false); + } + + private static async ValueTask + AddMotionDeviceSystemCoreAsync( + IRoboticsBuildContext buildContext, + QualifiedName browseName, + Action configure, + CancellationToken cancellationToken) + { + RoboticsBuildScope? scope = null; + bool registrationSucceeded = false; + try + { + scope = new RoboticsBuildScope(buildContext, browseName); + configure(scope.RootBuilder); + + CancellationToken contextToken = buildContext.CancellationToken; + if (!contextToken.CanBeCanceled || + contextToken == cancellationToken || + !cancellationToken.CanBeCanceled) + { + CancellationToken effectiveToken = cancellationToken.CanBeCanceled + ? cancellationToken + : contextToken; + await scope.RegisterAsync(effectiveToken).ConfigureAwait(false); + } + else + { + using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + contextToken, + cancellationToken); + await scope.RegisterAsync(linkedSource.Token).ConfigureAwait(false); + } + + registrationSucceeded = true; + return scope.RootBuilder; + } + finally + { + if (!registrationSucceeded) + { + scope?.Abort(); + } + } + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/RoboticsBuildScope.cs b/src/Opc.Ua.Robotics.Server/Builders/RoboticsBuildScope.cs new file mode 100644 index 0000000000..4864b4f6e7 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/RoboticsBuildScope.cs @@ -0,0 +1,575 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal enum RoboticsSemanticReference + { + Controls, + HasSafetyStates, + Moves, + Requires, + HasSlave, + IsDrivenBy, + IsConnectedTo + } + + internal sealed class RoboticsBuildScope + { + private readonly List m_builders = []; + private readonly IRoboticsBuildCoordinator m_buildCoordinator; + private readonly List m_semanticReferences = []; + private IDisposable? m_rootBrowseNameReservation; + private bool m_registering; + + public RoboticsBuildScope( + IRoboticsBuildContext buildContext, + QualifiedName browseName) + { + BuildContext = buildContext ?? + throw new ArgumentNullException(nameof(buildContext)); + m_buildCoordinator = GetBuildCoordinator(buildContext); + EnsureContextMutable(buildContext); + Context = buildContext.Context; + + m_rootBrowseNameReservation = m_buildCoordinator.ReserveRootBrowseName( + BuildContext.DeviceSet, + browseName); + try + { + Root = Context.CreateInstanceOfMotionDeviceSystemType( + BuildContext.DeviceSet, + browseName); + Root.ReferenceTypeId = global::Opc.Ua.ReferenceTypeIds.HasComponent; + RootBuilder = new MotionDeviceSystemBuilder(this, Root); + } + catch + { + ReleaseRootBrowseNameReservation(); + throw; + } + } + + internal IRoboticsBuildContext BuildContext { get; } + + internal ISystemContext Context { get; } + + internal MotionDeviceSystemState Root { get; } + + internal MotionDeviceSystemBuilder RootBuilder { get; } + + internal List Controllers { get; } = []; + + internal List MotionDevices { get; } = []; + + internal List SafetyStates { get; } = []; + + internal List Axes { get; } = []; + + internal List PowerTrains { get; } = []; + + internal List Gears { get; } = []; + + internal List EmergencyStops { get; } = []; + + internal List ProtectiveStops { get; } = []; + + internal List TaskModules { get; } = []; + + /// + /// Asynchronous work that must run after the completed tree has been + /// registered with the node manager, for example binding a Controller + /// Programs directory to a file-system provider. + /// + internal List> PostRegistrationActions { get; } = []; + + /// + /// Resources created during registration that must be released when the + /// build is rolled back. + /// + internal List RegisteredResources { get; } = []; + + internal bool IsRegistered { get; private set; } + + internal void Abort() + { + ReleaseReservations(); + } + + internal static void EnsureContextMutable(IRoboticsBuildContext buildContext) + { + IRoboticsBuildCoordinator coordinator = GetBuildCoordinator(buildContext); + if (coordinator.IsSealed) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "The Robotics build context is sealed."); + } + } + + internal static IDisposable AcquireBuildLease(IRoboticsBuildContext buildContext) + { + return GetBuildCoordinator(buildContext).AcquireBuildLease(); + } + + internal void EnsureMutable() + { + EnsureContextMutable(BuildContext); + if (m_registering || IsRegistered) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "MotionDeviceSystem '{0}' has already started registration.", + Root.BrowseName); + } + } + + internal void RegisterBuilder(RoboticsNodeBuilder builder) + { + m_builders.Add(builder); + } + + internal void AddSemanticReference( + RoboticsSemanticReference reference, + RoboticsNodeBuilder source, + RoboticsNodeBuilder target) + { + EnsureMutable(); + EnsureSameScope(source); + EnsureSameScope(target); + + for (int ii = 0; ii < m_semanticReferences.Count; ii++) + { + SemanticReference candidate = m_semanticReferences[ii]; + if (candidate.Reference == reference && + ReferenceEquals(candidate.Source, source) && + ReferenceEquals(candidate.Target, target)) + { + return; + } + } + m_semanticReferences.Add(new SemanticReference(reference, source, target)); + } + + internal void AddTaskControlRelation( + TaskControlBuilder taskControl, + MotionDeviceBuilder motionDevice) + { + EnsureMutable(); + EnsureSameScope(taskControl); + EnsureSameScope(motionDevice); + + AddSemanticReference( + RoboticsSemanticReference.Controls, + taskControl, + motionDevice); + } + + internal async ValueTask RegisterAsync(CancellationToken cancellationToken) + { + EnsureMutable(); + m_registering = true; + try + { + cancellationToken.ThrowIfCancellationRequested(); + Validate(); + + VerifyNodeIds(); + ApplySemanticReferences(); + + BuildContext.DeviceSet.AddChild(Root); + await BuildContext.Manager + .AddPredefinedNodeAsync(Root, cancellationToken) + .ConfigureAwait(false); + + CacheNodeBuilders(); + + for (int ii = 0; ii < PostRegistrationActions.Count; ii++) + { + await PostRegistrationActions[ii](cancellationToken).ConfigureAwait(false); + } + + IsRegistered = true; + } + catch + { + IsRegistered = false; + await RollbackRegistrationAsync().ConfigureAwait(false); + throw; + } + finally + { + ReleaseReservations(); + m_registering = false; + } + } + + private void Validate() + { + var errors = new List(); + if (Controllers.Count == 0) + { + errors.Add("The motion-device system must contain at least one controller."); + } + if (MotionDevices.Count == 0) + { + errors.Add("The motion-device system must contain at least one motion device."); + } + if (SafetyStates.Count == 0) + { + errors.Add("The motion-device system must contain at least one safety state."); + } + + for (int ii = 0; ii < Controllers.Count; ii++) + { + ControllerBuilder controller = Controllers[ii]; + if (controller.Software.Count == 0) + { + errors.Add( + $"Controller '{NameOf(controller)}' must contain at least one software instance."); + } + if (controller.TaskControls.Count == 0) + { + errors.Add( + $"Controller '{NameOf(controller)}' must contain at least one task control."); + } + if (controller.State.CurrentUser == null) + { + errors.Add( + $"Controller '{NameOf(controller)}' must define mandatory CurrentUser."); + } + } + + for (int ii = 0; ii < MotionDevices.Count; ii++) + { + MotionDeviceBuilder motionDevice = MotionDevices[ii]; + if (motionDevice.Axes.Count == 0) + { + errors.Add( + $"Motion device '{NameOf(motionDevice)}' must contain at least one axis."); + } + if (motionDevice.PowerTrains.Count == 0) + { + errors.Add( + $"Motion device '{NameOf(motionDevice)}' must contain at least one power train."); + } + } + + for (int ii = 0; ii < Axes.Count; ii++) + { + AxisBuilder axis = Axes[ii]; + if (!axis.IsVirtual && axis.RequiredPowerTrains.Count == 0) + { + errors.Add( + $"Non-virtual axis '{NameOf(axis)}' must Requires at least one power train."); + } + } + + for (int ii = 0; ii < PowerTrains.Count; ii++) + { + PowerTrainBuilder powerTrain = PowerTrains[ii]; + if (powerTrain.Motors.Count == 0) + { + errors.Add( + $"Power train '{NameOf(powerTrain)}' must contain at least one motor."); + } + } + + for (int ii = 0; ii < Gears.Count; ii++) + { + if (!Gears[ii].HasGearRatio) + { + errors.Add( + $"Gear '{NameOf(Gears[ii])}' must define a non-zero gear ratio denominator."); + } + } + + for (int ii = 0; ii < EmergencyStops.Count; ii++) + { + if (!EmergencyStops[ii].HasName) + { + errors.Add( + $"Emergency stop '{NameOf(EmergencyStops[ii])}' must define Name."); + } + } + + for (int ii = 0; ii < ProtectiveStops.Count; ii++) + { + if (!ProtectiveStops[ii].HasName) + { + errors.Add( + $"Protective stop '{NameOf(ProtectiveStops[ii])}' must define Name."); + } + } + + for (int ii = 0; ii < TaskModules.Count; ii++) + { + if (!TaskModules[ii].HasName) + { + errors.Add( + $"Task module '{NameOf(TaskModules[ii])}' must define Name."); + } + } + + if (errors.Count > 0) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "MotionDeviceSystem '{0}' is invalid: {1}", + Root.BrowseName, + string.Join(" ", errors)); + } + } + + private void VerifyNodeIds() + { + var nodes = new List { Root }; + var nodeIds = new HashSet(); + var children = new List(); + for (int ii = 0; ii < nodes.Count; ii++) + { + NodeState node = nodes[ii]; + if (node.NodeId.IsNull) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Robotics descendant '{0}' has a null NodeId after instance assignment.", + node.BrowseName); + } + if (node.NodeId.NamespaceIndex != BuildContext.InstanceNamespaceIndex) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Robotics descendant '{0}' has NodeId '{1}' outside the instance namespace.", + node.BrowseName, + node.NodeId); + } + if (!nodeIds.Add(node.NodeId)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Robotics descendant '{0}' has duplicate NodeId '{1}'.", + node.BrowseName, + node.NodeId); + } + NodeState? indexedNode = + BuildContext.Manager.FindPredefinedNode(node.NodeId); + if (indexedNode != null && !ReferenceEquals(indexedNode, node)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Robotics descendant '{0}' has NodeId '{1}', which is already indexed " + + "by node '{2}'.", + node.BrowseName, + node.NodeId, + indexedNode.BrowseName); + } + + children.Clear(); + node.GetChildren(Context, children); + for (int childIndex = 0; childIndex < children.Count; childIndex++) + { + nodes.Add(children[childIndex]); + } + } + } + + private static IRoboticsBuildCoordinator GetBuildCoordinator( + IRoboticsBuildContext buildContext) + { + return buildContext as IRoboticsBuildCoordinator ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The Robotics build context does not provide build coordination."); + } + + private void CacheNodeBuilders() + { + for (int ii = 0; ii < m_builders.Count; ii++) + { + m_builders[ii].CacheNodeBuilder(); + } + } + + private void ReleaseNodeIdReservations() + { + if (!m_buildCoordinator.ReleasesNodeIdReservations) + { + return; + } + + var nodes = new List { Root }; + var children = new List(); + for (int ii = 0; ii < nodes.Count; ii++) + { + NodeState node = nodes[ii]; + m_buildCoordinator.ReleaseNodeIdReservation(node); + + children.Clear(); + node.GetChildren(Context, children); + for (int childIndex = 0; childIndex < children.Count; childIndex++) + { + nodes.Add(children[childIndex]); + } + } + } + + private void ReleaseReservations() + { + ReleaseNodeIdReservations(); + ReleaseRootBrowseNameReservation(); + } + + private void ReleaseRootBrowseNameReservation() + { + m_rootBrowseNameReservation?.Dispose(); + m_rootBrowseNameReservation = null; + } + + private async ValueTask RollbackRegistrationAsync() + { + for (int ii = RegisteredResources.Count - 1; ii >= 0; ii--) + { + try + { + await RegisteredResources[ii].DisposeAsync().ConfigureAwait(false); + } + catch (Exception) + { + // A resource that fails to release must not mask the original + // build failure that triggered the rollback. + } + } + RegisteredResources.Clear(); + + try + { + if (!Root.NodeId.IsNull && + BuildContext.Manager.FindPredefinedNode(Root.NodeId) != null) + { + await BuildContext.Manager.DeleteNodeAsync( + BuildContext.Manager.SystemContext, + Root.NodeId, + CancellationToken.None).ConfigureAwait(false); + } + } + finally + { + BuildContext.DeviceSet.RemoveChild(Root); + } + } + + private void ApplySemanticReferences() + { + for (int ii = 0; ii < m_semanticReferences.Count; ii++) + { + SemanticReference relation = m_semanticReferences[ii]; + NodeId referenceTypeId = ResolveReferenceType(relation.Reference); + NodeState source = relation.Source.UntypedState; + NodeState target = relation.Target.UntypedState; + + if (!source.ReferenceExists(referenceTypeId, false, target.NodeId)) + { + source.AddReference(referenceTypeId, false, target.NodeId); + } + if (!target.ReferenceExists(referenceTypeId, true, source.NodeId)) + { + target.AddReference(referenceTypeId, true, source.NodeId); + } + + if (relation.Reference == RoboticsSemanticReference.Controls && + relation.Source is TaskControlBuilder taskControl && + relation.Target is MotionDeviceBuilder motionDevice && + taskControl.TaskControlOperation != null) + { + motionDevice.SetTaskControlReference(taskControl.TaskControlOperation.State); + } + } + } + + private NodeId ResolveReferenceType(RoboticsSemanticReference reference) + { + uint identifier = reference switch + { + RoboticsSemanticReference.Controls => ReferenceTypes.Controls, + RoboticsSemanticReference.HasSafetyStates => ReferenceTypes.HasSafetyStates, + RoboticsSemanticReference.Moves => ReferenceTypes.Moves, + RoboticsSemanticReference.Requires => ReferenceTypes.Requires, + RoboticsSemanticReference.HasSlave => ReferenceTypes.HasSlave, + RoboticsSemanticReference.IsDrivenBy => ReferenceTypes.IsDrivenBy, + RoboticsSemanticReference.IsConnectedTo => ReferenceTypes.IsConnectedTo, + _ => throw new ArgumentOutOfRangeException(nameof(reference)) + }; + return NodeId.Create( + identifier, + Namespaces.Robotics, + Context.NamespaceUris); + } + + private void EnsureSameScope(RoboticsNodeBuilder builder) + { + if (!ReferenceEquals(builder.Scope, this)) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidArgument, + "Robotics relationship endpoints must belong to the same build scope."); + } + } + + private static string NameOf(RoboticsNodeBuilder builder) + { + return builder.UntypedState.BrowseName.Name ?? ""; + } + + private sealed class SemanticReference + { + public SemanticReference( + RoboticsSemanticReference reference, + RoboticsNodeBuilder source, + RoboticsNodeBuilder target) + { + Reference = reference; + Source = source; + Target = target; + } + + public RoboticsSemanticReference Reference { get; } + + public RoboticsNodeBuilder Source { get; } + + public RoboticsNodeBuilder Target { get; } + } + + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/RoboticsBuilderUtilities.cs b/src/Opc.Ua.Robotics.Server/Builders/RoboticsBuilderUtilities.cs new file mode 100644 index 0000000000..60df9abc32 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/RoboticsBuilderUtilities.cs @@ -0,0 +1,459 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Di; +using Opc.Ua.Di.Server.Builders; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal static class RoboticsBuilderUtilities + { + public static QualifiedName NormalizeBrowseName( + IRoboticsBuildContext context, + string browseName) + { + if (string.IsNullOrWhiteSpace(browseName)) + { + throw new ArgumentException( + "A non-empty browse name is required.", + nameof(browseName)); + } + return new QualifiedName(browseName, context.InstanceNamespaceIndex); + } + + public static QualifiedName NormalizeBrowseName( + IRoboticsBuildContext context, + QualifiedName browseName) + { + if (browseName.IsNull || string.IsNullOrWhiteSpace(browseName.Name)) + { + throw new ArgumentException( + "A non-empty browse name is required.", + nameof(browseName)); + } + return new QualifiedName(browseName.Name, context.InstanceNamespaceIndex); + } + + public static void EnsureBrowseNameAvailable( + ISystemContext context, + NodeState parent, + QualifiedName browseName) + { + var children = new List(); + parent.GetChildren(context, children); + for (int ii = 0; ii < children.Count; ii++) + { + if (children[ii].BrowseName == browseName) + { + throw ServiceResultException.Create( + StatusCodes.BadBrowseNameDuplicated, + "Parent '{0}' already contains a child named '{1}'.", + parent.BrowseName, + browseName); + } + } + } + + public static TState AddContained( + ISystemContext context, + NodeState parent, + QualifiedName browseName, + Func factory) + where TState : BaseInstanceState + { + return AddContained( + context, + parent, + browseName, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + factory); + } + + public static TState AddContained( + ISystemContext context, + NodeState parent, + QualifiedName browseName, + NodeId referenceTypeId, + Func factory) + where TState : BaseInstanceState + { + EnsureBrowseNameAvailable(context, parent, browseName); + TState state = factory(parent, browseName) ?? + throw ServiceResultException.Create( + StatusCodes.BadUnexpectedError, + "The generated factory returned null for '{0}'.", + browseName); + state.ReferenceTypeId = referenceTypeId; + parent.AddChild(state); + return state; + } + + public static TState AddGeneratedChild( + ISystemContext context, + NodeState parent, + Func factory) + where TState : BaseInstanceState + { + TState state = factory(parent) ?? + throw ServiceResultException.Create( + StatusCodes.BadUnexpectedError, + "The generated child factory returned null below '{0}'.", + parent.BrowseName); + parent.AddChild(state); + NodeId previousNodeId = context.AssignInstanceNodeId(state); + context.AssignInstanceChildNodeIds(state, previousNodeId, parent); + return state; + } + + public static TChild FindRequiredChild( + ISystemContext context, + NodeState parent, + string browseName) + where TChild : BaseInstanceState + { + TChild? child = FindChild(context, parent, browseName); + if (child == null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory child '{0}' is missing below '{1}'.", + browseName, + parent.BrowseName); + } + return child; + } + + public static TChild? FindChild( + ISystemContext context, + NodeState parent, + string browseName) + where TChild : BaseInstanceState + { + var children = new List(); + parent.GetChildren(context, children); + for (int ii = 0; ii < children.Count; ii++) + { + if (children[ii] is TChild typed && + string.Equals( + children[ii].BrowseName.Name, + browseName, + StringComparison.Ordinal)) + { + return typed; + } + } + return null; + } + + public static void SetComponentName( + ComponentState state, + ISystemContext context, + LocalizedText componentName) + { + if (componentName.IsNull) + { + throw new ArgumentException( + "A non-null component name is required.", + nameof(componentName)); + } + state.AddComponentName(context); + SetValue(state.ComponentName!, componentName); + } + + public static void SetProductCode( + ComponentState state, + ISystemContext context, + string productCode) + { + if (productCode == null) + { + throw new ArgumentNullException(nameof(productCode)); + } + state.AddProductCode(context); + SetValue(state.ProductCode!, productCode); + } + + public static void SetAssetId( + ComponentState state, + ISystemContext context, + string assetId) + { + if (assetId == null) + { + throw new ArgumentNullException(nameof(assetId)); + } + state.AddAssetId(context); + SetValue(state.AssetId!, assetId); + } + + public static void ApplyIdentification( + ComponentState state, + ISystemContext context, + Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + var data = new DeviceIdentificationData(); + configure(data); + + if (!data.Manufacturer.IsNull) + { + state.AddManufacturer(context); + SetValue(state.Manufacturer!, data.Manufacturer); + } + if (data.ManufacturerUri != null) + { + state.AddManufacturerUri(context); + SetValue(state.ManufacturerUri!, data.ManufacturerUri); + } + if (!data.Model.IsNull) + { + state.AddModel(context); + SetValue(state.Model!, data.Model); + } + if (data.HardwareRevision != null) + { + state.AddHardwareRevision(context); + SetValue(state.HardwareRevision!, data.HardwareRevision); + } + if (data.SoftwareRevision != null) + { + state.AddSoftwareRevision(context); + SetValue(state.SoftwareRevision!, data.SoftwareRevision); + } + if (data.DeviceRevision != null) + { + state.AddDeviceRevision(context); + SetValue(state.DeviceRevision!, data.DeviceRevision); + } + if (data.ProductCode != null) + { + state.AddProductCode(context); + SetValue(state.ProductCode!, data.ProductCode); + } + if (data.DeviceManual != null) + { + state.AddDeviceManual(context); + SetValue(state.DeviceManual!, data.DeviceManual); + } + if (data.DeviceClass != null) + { + state.AddDeviceClass(context); + SetValue(state.DeviceClass!, data.DeviceClass); + } + if (data.SerialNumber != null) + { + state.AddSerialNumber(context); + SetValue(state.SerialNumber!, data.SerialNumber); + } + if (data.ProductInstanceUri != null) + { + state.AddProductInstanceUri(context); + SetValue(state.ProductInstanceUri!, data.ProductInstanceUri); + } + if (data.RevisionCounter.HasValue) + { + state.AddRevisionCounter(context); + SetValue(state.RevisionCounter!, data.RevisionCounter.Value); + } + } + + public static void SetValue( + BaseDataVariableState variable, + T value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + variable.Value = value; + SetValueAttributes(variable, statusCode, timestamp); + } + + public static void SetValue( + PropertyState variable, + T value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + variable.Value = value; + SetValueAttributes(variable, statusCode, timestamp); + } + + public static void SetValue( + AnalogUnitState variable, + T value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + variable.Value = value; + SetValueAttributes(variable, statusCode, timestamp); + } + + private static void SetValueAttributes( + BaseVariableState variable, + StatusCode statusCode, + DateTimeUtc timestamp) + { + variable.StatusCode = statusCode; + variable.Timestamp = NormalizeTimestamp(timestamp); + } + + public static void SetAnalogValue( + AnalogUnitState variable, + ISystemContext context, + double value, + EUInformation? engineeringUnits, + global::Opc.Ua.Range? range, + StatusCode statusCode, + DateTimeUtc timestamp) + { + SetValue(variable, value, statusCode, timestamp); + if (engineeringUnits != null) + { + PropertyState property = + variable.EngineeringUnits ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory EngineeringUnits is missing below '{0}'.", + variable.BrowseName); + SetValue(property, engineeringUnits); + } + if (range != null) + { + variable.AddEURange(context); + SetValue(variable.EURange!, range); + } + } + + public static void BindRead( + BaseVariableState variable, + Func> read) + { + if (read == null) + { + throw new ArgumentNullException(nameof(read)); + } + if (variable.OnReadValueAsync != null || + variable.OnSimpleReadValueAsync != null) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "An asynchronous read callback is already attached to '{0}'.", + variable.BrowseName); + } + + variable.AccessLevel |= AccessLevels.CurrentRead; + variable.UserAccessLevel |= AccessLevels.CurrentRead; + variable.OnReadValueAsync = async ( + context, + _, + indexRange, + dataEncoding, + cancellationToken) => + { + DataValue dataValue = await read(cancellationToken).ConfigureAwait(false); + if (dataValue.IsNull) + { + return new AttributeReadResult( + StatusCodes.BadNoDataAvailable, + Variant.Null, + StatusCodes.BadNoDataAvailable, + DateTimeUtc.MinValue); + } + + Variant value = dataValue.WrappedValue; + if (!StatusCode.IsBad(dataValue.StatusCode)) + { + ServiceResult result = BaseVariableState.ApplyIndexRangeAndDataEncoding( + context, + indexRange, + dataEncoding, + ref value); + if (ServiceResult.IsBad(result)) + { + return new AttributeReadResult( + result, + Variant.Null, + result.StatusCode, + dataValue.SourceTimestamp); + } + + if (variable.CopyPolicy is + VariableCopyPolicy.CopyOnRead or VariableCopyPolicy.Always) + { + value = CoreUtils.Clone(value); + } + } + + return new AttributeReadResult( + ServiceResult.Good, + value, + dataValue.StatusCode, + dataValue.SourceTimestamp); + }; + } + + public static void BindWrite( + BaseVariableState variable, + Func> write) + { + if (write == null) + { + throw new ArgumentNullException(nameof(write)); + } + if (variable.OnWriteValueAsync != null || + variable.OnSimpleWriteValueAsync != null) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "An asynchronous write callback is already attached to '{0}'.", + variable.BrowseName); + } + + variable.AccessLevel |= AccessLevels.CurrentWrite; + variable.UserAccessLevel |= AccessLevels.CurrentWrite; + variable.OnSimpleWriteValueAsync = async (_, _, value, cancellationToken) => + { + ServiceResult result = await write(value, cancellationToken) + .ConfigureAwait(false); + return new AttributeWriteResult(result ?? ServiceResult.Good); + }; + } + + private static DateTimeUtc NormalizeTimestamp(DateTimeUtc timestamp) + { + return timestamp == DateTimeUtc.MinValue ? DateTimeUtc.Now : timestamp; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/RoboticsNodeBuilder.cs b/src/Opc.Ua.Robotics.Server/Builders/RoboticsNodeBuilder.cs new file mode 100644 index 0000000000..061310647e --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/RoboticsNodeBuilder.cs @@ -0,0 +1,155 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Server.Fluent; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal abstract class RoboticsNodeBuilder + { + protected RoboticsNodeBuilder(RoboticsBuildScope scope, NodeState state) + { + Scope = scope ?? throw new ArgumentNullException(nameof(scope)); + UntypedState = state ?? throw new ArgumentNullException(nameof(state)); + scope.RegisterBuilder(this); + } + + internal RoboticsBuildScope Scope { get; } + + internal NodeState UntypedState { get; } + + internal abstract void CacheNodeBuilder(); + } + + internal abstract class RoboticsNodeBuilder : + RoboticsNodeBuilder, + IRoboticsNodeBuilder + where TState : NodeState + { + private INodeBuilder? m_nodeBuilder; + + protected RoboticsNodeBuilder(RoboticsBuildScope scope, TState state) + : base(scope, state) + { + State = state; + } + + public TState State { get; } + + public IRoboticsBuildContext BuildContext => Scope.BuildContext; + + public IRoboticsNodeBuilder Configure( + Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + Scope.EnsureMutable(); + configure(State, Scope.Context); + return this; + } + + public INodeBuilder AsNode() + { + if (!Scope.IsRegistered) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "Robotics node '{0}' is unavailable through the fluent node surface " + + "until its motion-device system has been registered.", + State.BrowseName); + } + return m_nodeBuilder ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Robotics node '{0}' was registered without caching its fluent node builder.", + State.BrowseName); + } + + internal override void CacheNodeBuilder() + { + m_nodeBuilder ??= BuildContext.Nodes.Node(State.NodeId); + } + + protected RoboticsNodeBuilder RequireSameScope( + IRoboticsNodeBuilder other, + string parameterName) + where TOther : NodeState + { + if (other == null) + { + throw new ArgumentNullException(parameterName); + } + if (other is not RoboticsNodeBuilder builder || + !ReferenceEquals(builder.Scope, Scope)) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidArgument, + "Relationship endpoint '{0}' must belong to the same " + + "MotionDeviceSystem build scope as '{1}'.", + other.State.BrowseName, + State.BrowseName); + } + return builder; + } + + protected TBuilder RequireSameScope( + IRoboticsNodeBuilder other, + string parameterName) + where TBuilder : RoboticsNodeBuilder + where TOther : NodeState + { + RoboticsNodeBuilder builder = RequireSameScope(other, parameterName); + if (builder is not TBuilder typed) + { + throw ServiceResultException.Create( + StatusCodes.BadTypeMismatch, + "Relationship endpoint '{0}' is not a {1}.", + other.State.BrowseName, + typeof(TBuilder).Name); + } + return typed; + } + + protected TBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TBuilder : RoboticsNodeBuilder + where TOther : NodeState + { + RoboticsNodeBuilder target = RequireSameScope(other, nameof(other)); + Scope.AddSemanticReference( + RoboticsSemanticReference.IsConnectedTo, + this, + target); + return (TBuilder)this; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/RoboticsOperationsBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/RoboticsOperationsBuilders.cs new file mode 100644 index 0000000000..db7e89c02a --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/RoboticsOperationsBuilders.cs @@ -0,0 +1,813 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Robotics.Operations; +using UaBrowseNames = global::Opc.Ua.BrowseNames; +using UaDataTypeIds = global::Opc.Ua.DataTypeIds; +using UaObjectTypeIds = global::Opc.Ua.ObjectTypeIds; +using UaReferenceTypeIds = global::Opc.Ua.ReferenceTypeIds; +using UaVariableTypeIds = global::Opc.Ua.VariableTypeIds; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Builds an opt-in, non-normative Robotics operation convention object. + /// + public interface IRoboticsOperationsBuilder + { + /// + /// Gets the operation object state after it is materialized. + /// + BaseObjectState? State { get; } + + /// + /// Supplies the dynamic UserExecutable decision for every convention method. + /// + IRoboticsOperationsBuilder WithUserExecutable( + Func isUserExecutable); + + /// + /// Registers the non-normative MoveTo convention handler. + /// + IRoboticsOperationsBuilder OnMoveTo( + Func> handler); + + /// + /// Registers the non-normative MoveJ convention handler. + /// + IRoboticsOperationsBuilder OnMoveJ( + Func> handler); + + /// + /// Registers the non-normative MoveL convention handler. + /// + IRoboticsOperationsBuilder OnMoveL( + Func> handler); + + /// + /// Registers the non-normative Grasp convention handler. + /// + IRoboticsOperationsBuilder OnGrasp( + Func> handler); + + /// + /// Registers the non-normative Release convention handler. + /// + IRoboticsOperationsBuilder OnRelease( + Func> handler); + + /// + /// Registers the non-normative PickFrom convention handler. + /// + IRoboticsOperationsBuilder OnPickFrom( + Func> handler); + + /// + /// Registers the non-normative PlaceAt convention handler. + /// + IRoboticsOperationsBuilder OnPlaceAt( + Func> handler); + + /// + /// Registers the non-normative SwapTool convention handler. + /// + IRoboticsOperationsBuilder OnSwapTool( + Func> handler); + + /// + /// Registers the non-normative SetOutput convention handler. + /// + IRoboticsOperationsBuilder OnSetOutput( + Func> handler); + + /// + /// Registers the non-normative CallProgram fallback handler. + /// + /// + /// Prefer the standard OPC UA Programs plus OPC 40010 TaskControl route for program loading and + /// execution. Use this operation only as an application-specific fallback when that standard model + /// cannot represent the target program invocation. + /// + IRoboticsOperationsBuilder OnCallProgram( + Func> handler); + + /// + /// Adds an application-specific operation outside the industrial convention subset. + /// + /// + /// The request CLR type. + /// + /// + /// The response CLR type. + /// + IRoboticsOperationsBuilder AddOperation( + string name, + Func> handler); + } + + internal sealed class RoboticsOperationsBuilder : IRoboticsOperationsBuilder + { + public RoboticsOperationsBuilder( + RoboticsBuildScope scope, + MotionDeviceState owner, + string browseName, + ushort applicationNamespaceIndex) + { + m_scope = scope ?? throw new ArgumentNullException(nameof(scope)); + m_owner = owner ?? throw new ArgumentNullException(nameof(owner)); + m_browseName = string.IsNullOrWhiteSpace(browseName) + ? throw new ArgumentException("A non-empty browse name is required.", nameof(browseName)) + : browseName; + m_applicationNamespaceIndex = applicationNamespaceIndex; + ValidateApplicationNamespace(scope, applicationNamespaceIndex); + scope.PostRegistrationActions.Add(MaterializeAsync); + } + + public BaseObjectState? State { get; private set; } + + public IRoboticsOperationsBuilder WithUserExecutable( + Func isUserExecutable) + { + m_scope.EnsureMutable(); + m_isUserExecutable = isUserExecutable ?? throw new ArgumentNullException(nameof(isUserExecutable)); + return this; + } + + public IRoboticsOperationsBuilder OnMoveTo( + Func> handler) + { + return AddStandardOperation("MoveTo", handler, CreateMoveTo, MoveToArguments()); + } + + public IRoboticsOperationsBuilder OnMoveJ( + Func> handler) + { + return AddStandardOperation("MoveJ", handler, CreateMoveJ, MoveJArguments()); + } + + public IRoboticsOperationsBuilder OnMoveL( + Func> handler) + { + return AddStandardOperation("MoveL", handler, CreateMoveL, MoveLArguments()); + } + + public IRoboticsOperationsBuilder OnGrasp( + Func> handler) + { + return AddStandardOperation("Grasp", handler, CreateGrasp, GraspArguments()); + } + + public IRoboticsOperationsBuilder OnRelease( + Func> handler) + { + return AddStandardOperation("Release", handler, CreateRelease, ReleaseArguments()); + } + + public IRoboticsOperationsBuilder OnPickFrom( + Func> handler) + { + return AddStandardOperation("PickFrom", handler, CreatePickPlace, PickPlaceArguments()); + } + + public IRoboticsOperationsBuilder OnPlaceAt( + Func> handler) + { + return AddStandardOperation("PlaceAt", handler, CreatePickPlace, PickPlaceArguments()); + } + + public IRoboticsOperationsBuilder OnSwapTool( + Func> handler) + { + return AddStandardOperation("SwapTool", handler, CreateToolChange, ToolChangeArguments()); + } + + public IRoboticsOperationsBuilder OnSetOutput( + Func> handler) + { + return AddStandardOperation("SetOutput", handler, CreateOutput, OutputArguments()); + } + + public IRoboticsOperationsBuilder OnCallProgram( + Func> handler) + { + return AddStandardOperation("CallProgram", handler, CreateProgramCall, ProgramCallArguments()); + } + + public IRoboticsOperationsBuilder AddOperation( + string name, + Func> handler) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("A non-empty operation name is required.", nameof(name)); + } + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + m_scope.EnsureMutable(); + AddRegistration(new GenericOperationRegistration(name, handler)); + return this; + } + + private RoboticsOperationsBuilder AddStandardOperation( + string name, + Func> handler, + Func, TRequest> createRequest, + ArrayOf inputArguments) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + m_scope.EnsureMutable(); + AddRegistration(new StandardOperationRegistration( + name, + handler, + createRequest, + inputArguments)); + return this; + } + + private void AddRegistration(OperationRegistration registration) + { + for (int ii = 0; ii < m_registrations.Count; ii++) + { + if (string.Equals(m_registrations[ii].Name, registration.Name, StringComparison.Ordinal)) + { + throw ServiceResultException.Create( + StatusCodes.BadBrowseNameDuplicated, + "Operation '{0}' is already registered.", + registration.Name); + } + } + m_registrations.Add(registration); + } + + private async ValueTask MaterializeAsync(CancellationToken cancellationToken) + { + if (m_registrations.Count == 0) + { + return; + } + + BaseObjectState operations = CreateOperationsObject(); + State = operations; + m_owner.AddChild(operations); + for (int ii = 0; ii < m_registrations.Count; ii++) + { + CreateMethod(operations, m_registrations[ii]); + } + await m_scope.BuildContext.Manager + .AddPredefinedNodeAsync(operations, cancellationToken) + .ConfigureAwait(false); + } + + private BaseObjectState CreateOperationsObject() + { + var browseName = new QualifiedName(m_browseName, m_applicationNamespaceIndex); + var state = new BaseObjectState(m_owner) + { + BrowseName = browseName, + DisplayName = new LocalizedText(m_browseName), + Description = new LocalizedText( + "Opt-in, explicitly non-normative industrial operation conventions. " + + "This object is application-owned and is not part of OPC 40010."), + NodeId = ChildNodeId(m_owner.NodeId, m_browseName), + ReferenceTypeId = UaReferenceTypeIds.HasComponent, + SymbolicName = m_browseName, + TypeDefinitionId = UaObjectTypeIds.BaseObjectType + }; + state.AddReference(UaReferenceTypeIds.HasComponent, true, m_owner.NodeId); + return state; + } + + private void CreateMethod(BaseObjectState parent, OperationRegistration registration) + { + var method = new MethodState(parent) + { + BrowseName = new QualifiedName(registration.Name, m_applicationNamespaceIndex), + DisplayName = new LocalizedText(registration.Name), + Description = new LocalizedText( + "Opt-in, explicitly non-normative Robotics operation convention method. " + + "This is not an OPC 40010 method."), + Executable = true, + NodeId = ChildNodeId(parent.NodeId, registration.Name), + ReferenceTypeId = UaReferenceTypeIds.HasComponent, + SymbolicName = registration.Name, + UserExecutable = true + }; + method.AddReference(UaReferenceTypeIds.HasComponent, true, parent.NodeId); + method.OnReadUserExecutable = OnReadUserExecutable; + method.OnCallMethod2Async = registration.InvokeAsync; + parent.AddChild(method); + AddArgumentProperty(method, UaBrowseNames.InputArguments, "InputArguments", registration.InputArguments); + AddArgumentProperty(method, UaBrowseNames.OutputArguments, "OutputArguments", registration.OutputArguments); + } + + private ServiceResult OnReadUserExecutable(ISystemContext context, NodeState node, ref bool value) + { + if (node is MethodState method && m_isUserExecutable != null) + { + value = m_isUserExecutable(context, method); + } + return ServiceResult.Good; + } + + private void AddArgumentProperty( + MethodState method, + string browseName, + string suffix, + ArrayOf arguments) + { + var property = PropertyState>.With>(method); + property.BrowseName = new QualifiedName(browseName); + property.DataType = UaDataTypeIds.Argument; + property.DisplayName = new LocalizedText(browseName); + property.NodeId = ChildNodeId(method.NodeId, suffix); + property.ReferenceTypeId = UaReferenceTypeIds.HasProperty; + property.TypeDefinitionId = UaVariableTypeIds.PropertyType; + property.Value = arguments; + property.ValueRank = ValueRanks.OneDimension; + if (browseName == UaBrowseNames.InputArguments) + { + method.InputArguments = property; + } + else + { + method.OutputArguments = property; + } + method.AddChild(property); + } + + private NodeId ChildNodeId(NodeId parentNodeId, string name) + { + return new NodeId( + $"{parentNodeId.IdentifierAsString}_{name}", + m_applicationNamespaceIndex); + } + + private static void ValidateApplicationNamespace( + RoboticsBuildScope scope, + ushort applicationNamespaceIndex) + { + string? namespaceUri = scope.Context.NamespaceUris.GetString(applicationNamespaceIndex); + if (namespaceUri == null || + applicationNamespaceIndex == 0 || + namespaceUri == Opc.Ua.Di.Namespaces.OpcUaDi || + namespaceUri == Opc.Ua.IA.Namespaces.IA || + namespaceUri == Opc.Ua.Robotics.Namespaces.Robotics) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Robotics operation conventions must be created in an application-owned namespace."); + } + } + + private static Argument Argument(string name, NodeId dataType, int valueRank = ValueRanks.Scalar) + { + return new Argument + { + Name = name, + DataType = dataType, + ValueRank = valueRank + }; + } + + private static ArrayOf ResultArguments() + { + return [ + Argument("StatusCode", UaDataTypeIds.StatusCode), + Argument("Message", UaDataTypeIds.String), + Argument("Outputs", UaDataTypeIds.BaseDataType, ValueRanks.OneDimension) + ]; + } + + private static ArrayOf MoveToArguments() + { + return [ + Argument("TargetFrame", UaDataTypeIds.Structure), + Argument("SpeedFraction", UaDataTypeIds.Double), + Argument("BlendRadius", UaDataTypeIds.Double), + Argument("BlendRadiusUnits", UaDataTypeIds.EUInformation) + ]; + } + + private static ArrayOf MoveJArguments() + { + return [ + Argument("JointTargets", UaDataTypeIds.Double, ValueRanks.OneDimension), + Argument("JointUnits", UaDataTypeIds.EUInformation), + Argument("SpeedFraction", UaDataTypeIds.Double) + ]; + } + + private static ArrayOf MoveLArguments() + { + return [ + Argument("TargetFrame", UaDataTypeIds.Structure), + Argument("LinearSpeed", UaDataTypeIds.Double), + Argument("LinearSpeedUnits", UaDataTypeIds.EUInformation), + Argument("Acceleration", UaDataTypeIds.Double), + Argument("AccelerationUnits", UaDataTypeIds.EUInformation) + ]; + } + + private static ArrayOf GraspArguments() + { + return [ + Argument("ForceNewtons", UaDataTypeIds.Double), + Argument("Width", UaDataTypeIds.Double), + Argument("WidthUnits", UaDataTypeIds.EUInformation), + Argument("Approach", UaDataTypeIds.Int32) + ]; + } + + private static ArrayOf ReleaseArguments() + { + return [ + Argument("Mode", UaDataTypeIds.Int32), + Argument("TargetFrame", UaDataTypeIds.Structure) + ]; + } + + private static ArrayOf PickPlaceArguments() + { + return [ + Argument("StationOrLocationIdentifier", UaDataTypeIds.String), + Argument("ObjectClass", UaDataTypeIds.String), + Argument("Attributes", UaDataTypeIds.Structure, ValueRanks.OneDimension), + Argument("ForceNewtons", UaDataTypeIds.Double) + ]; + } + + private static ArrayOf ToolChangeArguments() + { + return [ + Argument("ToolIdentifier", UaDataTypeIds.String), + Argument("DockStation", UaDataTypeIds.String) + ]; + } + + private static ArrayOf OutputArguments() + { + return [ + Argument("OutputLineIdentifier", UaDataTypeIds.String), + Argument("Value", UaDataTypeIds.BaseDataType) + ]; + } + + private static ArrayOf ProgramCallArguments() + { + return [ + Argument("ProgramName", UaDataTypeIds.String), + Argument("Arguments", UaDataTypeIds.BaseDataType, ValueRanks.OneDimension) + ]; + } + + private static MoveToRequest CreateMoveTo(ArrayOf input) + { + return new MoveToRequest( + RequiredStructure(input, 0), + OptionalDouble(input, 1), + OptionalDouble(input, 2), + OptionalStructure(input, 3)); + } + + private static JointMoveRequest CreateMoveJ(ArrayOf input) + { + return new JointMoveRequest( + RequiredDoubleArray(input, 0), + RequiredStructure(input, 1), + OptionalDouble(input, 2)); + } + + private static LinearMoveRequest CreateMoveL(ArrayOf input) + { + return new LinearMoveRequest( + RequiredStructure(input, 0), + RequiredDouble(input, 1), + RequiredStructure(input, 2), + OptionalDouble(input, 3), + OptionalStructure(input, 4)); + } + + private static GraspRequest CreateGrasp(ArrayOf input) + { + return new GraspRequest( + OptionalDouble(input, 0), + OptionalDouble(input, 1), + OptionalStructure(input, 2), + (RoboticsApproach)RequiredInt32(input, 3)); + } + + private static ReleaseRequest CreateRelease(ArrayOf input) + { + return new ReleaseRequest( + (RoboticsReleaseMode)RequiredInt32(input, 0), + OptionalStructure(input, 1)); + } + + private static PickPlaceRequest CreatePickPlace(ArrayOf input) + { + return new PickPlaceRequest( + RequiredString(input, 0), + RequiredString(input, 1), + OptionalKeyValueArray(input, 2) ?? [], + OptionalDouble(input, 3)); + } + + private static ToolChangeRequest CreateToolChange(ArrayOf input) + { + return new ToolChangeRequest(RequiredString(input, 0), OptionalString(input, 1)); + } + + private static OutputRequest CreateOutput(ArrayOf input) + { + return new OutputRequest(RequiredString(input, 0), input[1]); + } + + private static ProgramCallRequest CreateProgramCall(ArrayOf input) + { + return new ProgramCallRequest(RequiredString(input, 0), OptionalVariantArray(input, 1) ?? []); + } + + private static T RequiredStructure(ArrayOf input, int index) + where T : class, IEncodeable + { +#pragma warning disable CS8600 // TODO: update when Variant.TryGetStructure carries class nullability. + if (input[index].TryGetStructure(out T value)) +#pragma warning restore CS8600 + { + return value!; + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private static T? OptionalStructure(ArrayOf input, int index) + where T : class, IEncodeable + { + if (input[index].IsNull) + { + return null; + } + return RequiredStructure(input, index); + } + + private static ArrayOf RequiredDoubleArray(ArrayOf input, int index) + { + if (input[index].TryGetValue(out ArrayOf value)) + { + return value; + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private static ArrayOf? OptionalKeyValueArray(ArrayOf input, int index) + { + if (input[index].IsNull) + { + return null; + } + if (input[index].TryGetValue(out ArrayOf value, null)) + { + return value; + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private static ArrayOf? OptionalVariantArray(ArrayOf input, int index) + { + if (input[index].IsNull) + { + return null; + } + if (input[index].TryGetValue(out ArrayOf value)) + { + return value; + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private static ArrayOf RequiredVariantArray(ArrayOf input, int index) + { + if (input[index].TryGetValue(out ArrayOf value)) + { + return value; + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private static double RequiredDouble(ArrayOf input, int index) + { + if (input[index].TryGetValue(out double value)) + { + return value; + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private static double? OptionalDouble(ArrayOf input, int index) + { + if (input[index].IsNull) + { + return null; + } + return RequiredDouble(input, index); + } + + private static int RequiredInt32(ArrayOf input, int index) + { + if (input[index].TryGetValue(out int value)) + { + return value; + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private static string RequiredString(ArrayOf input, int index) + { + if (input[index].TryGetValue(out string value)) + { + return value; + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private static string? OptionalString(ArrayOf input, int index) + { + if (input[index].IsNull) + { + return null; + } + return RequiredString(input, index); + } + + private readonly ushort m_applicationNamespaceIndex; + private readonly string m_browseName; + private readonly MotionDeviceState m_owner; + private readonly List m_registrations = []; + private readonly RoboticsBuildScope m_scope; + private Func? m_isUserExecutable; + + private abstract class OperationRegistration + { + protected OperationRegistration(string name, ArrayOf inputArguments) + { + Name = name; + InputArguments = inputArguments; + } + + public string Name { get; } + + public ArrayOf InputArguments { get; } + + public ArrayOf OutputArguments => ResultArguments(); + + public abstract ValueTask InvokeAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + ArrayOf inputArguments, + List outputArguments, + CancellationToken cancellationToken = default); + } + + private sealed class StandardOperationRegistration : OperationRegistration + { + public StandardOperationRegistration( + string name, + Func> handler, + Func, TRequest> createRequest, + ArrayOf inputArguments) + : base(name, inputArguments) + { + m_handler = handler; + m_createRequest = createRequest; + } + + public override async ValueTask InvokeAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + ArrayOf inputArguments, + List outputArguments, + CancellationToken cancellationToken = default) + { + TRequest request = m_createRequest(inputArguments); + RoboticsOperationResult result = await m_handler(request, cancellationToken) + .ConfigureAwait(false); + AddResultOutputs(result, outputArguments); + return result.ServiceResult; + } + + private readonly Func, TRequest> m_createRequest; + private readonly Func> m_handler; + } + + private sealed class GenericOperationRegistration : OperationRegistration + { + public GenericOperationRegistration( + string name, + Func> handler) + : base(name, [Argument("Arguments", UaDataTypeIds.BaseDataType, ValueRanks.OneDimension)]) + { + m_handler = handler; + } + + public override async ValueTask InvokeAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + ArrayOf inputArguments, + List outputArguments, + CancellationToken cancellationToken = default) + { + TRequest request = CreateGenericRequest(inputArguments); + TResponse response = await m_handler(request, cancellationToken).ConfigureAwait(false); + if (response is RoboticsOperationResult operationResult) + { + AddResultOutputs(operationResult, outputArguments); + return operationResult.ServiceResult; + } + if (response is ArrayOf variants) + { + var result = new RoboticsOperationResult(ServiceResult.Good, Outputs: variants); + AddResultOutputs(result, outputArguments); + return ServiceResult.Good; + } + outputArguments[0] = new Variant(StatusCodes.Good); + outputArguments[1] = Variant.Null; + outputArguments[2] = Variant.Null; + return ServiceResult.Good; + } + + private static TRequest CreateGenericRequest(ArrayOf inputArguments) + { + if (typeof(TRequest) == typeof(ArrayOf)) + { + ArrayOf arguments = RequiredVariantArray(inputArguments, 0); + return Unsafe.As, TRequest>(ref arguments); + } + if (typeof(TRequest) == typeof(Variant)) + { + ArrayOf arguments = RequiredVariantArray(inputArguments, 0); + Variant value = arguments.Count == 0 ? Variant.Null : arguments[0]; + return Unsafe.As(ref value); + } + throw new ServiceResultException(StatusCodes.BadTypeMismatch); + } + + private readonly Func> m_handler; + } + + private static void AddResultOutputs( + RoboticsOperationResult result, + List outputArguments) + { + outputArguments[0] = new Variant(result.ServiceResult.StatusCode); + outputArguments[1] = result.Message == null ? Variant.Null : new Variant(result.Message); + ArrayOf? outputs = result.Outputs; + if (outputs == null) + { + outputArguments[2] = Variant.Null; + } + else + { + ArrayOf values = outputs.Value; + outputArguments[2] = new Variant(values); + } + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/SafetyBuilderInterfaces.cs b/src/Opc.Ua.Robotics.Server/Builders/SafetyBuilderInterfaces.cs new file mode 100644 index 0000000000..63a785cc29 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/SafetyBuilderInterfaces.cs @@ -0,0 +1,203 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Builds aggregate and per-function safety state. + /// + public interface ISafetyStateBuilder : IRoboticsNodeBuilder + { + /// + /// Sets the optional component name. + /// + ISafetyStateBuilder WithComponentName(string componentName); + + /// + /// Sets the optional localized component name. + /// + ISafetyStateBuilder WithComponentName(LocalizedText componentName); + + /// + /// Sets the aggregate EmergencyStop value. + /// + ISafetyStateBuilder WithEmergencyStop( + bool value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Sets the aggregate OperationalMode value. + /// + ISafetyStateBuilder WithOperationalMode( + OperationalModeEnumeration value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Sets the aggregate ProtectiveStop value. + /// + ISafetyStateBuilder WithProtectiveStop( + bool value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Binds asynchronous aggregate EmergencyStop reads. + /// + ISafetyStateBuilder BindEmergencyStop( + Func> read); + + /// + /// Binds asynchronous aggregate OperationalMode reads. + /// + ISafetyStateBuilder BindOperationalMode( + Func> read); + + /// + /// Binds asynchronous aggregate ProtectiveStop reads. + /// + ISafetyStateBuilder BindProtectiveStop( + Func> read); + + /// + /// Adds an emergency-stop function. + /// + IEmergencyStopBuilder AddEmergencyStop( + string browseName, + Action? configure = null); + + /// + /// Adds and names an emergency-stop function. + /// + IEmergencyStopBuilder AddEmergencyStop( + string browseName, + string name, + Action? configure = null); + + /// + /// Adds an emergency-stop function. + /// + IEmergencyStopBuilder AddEmergencyStop( + QualifiedName browseName, + Action? configure = null); + + /// + /// Adds a protective-stop function. + /// + IProtectiveStopBuilder AddProtectiveStop( + string browseName, + Action? configure = null); + + /// + /// Adds and names a protective-stop function. + /// + IProtectiveStopBuilder AddProtectiveStop( + string browseName, + string name, + Action? configure = null); + + /// + /// Adds a protective-stop function. + /// + IProtectiveStopBuilder AddProtectiveStop( + QualifiedName browseName, + Action? configure = null); + } + + /// + /// Builds an EmergencyStopFunctionType instance. + /// + public interface IEmergencyStopBuilder : + IRoboticsNodeBuilder + { + /// + /// Sets the required function name. + /// + IEmergencyStopBuilder WithName(string name); + + /// + /// Sets Active. + /// + IEmergencyStopBuilder WithActive( + bool active, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Binds asynchronous Active reads. + /// + IEmergencyStopBuilder BindActive( + Func> read); + } + + /// + /// Builds a ProtectiveStopFunctionType instance. + /// + public interface IProtectiveStopBuilder : + IRoboticsNodeBuilder + { + /// + /// Sets the required function name. + /// + IProtectiveStopBuilder WithName(string name); + + /// + /// Sets Active. + /// + IProtectiveStopBuilder WithActive( + bool active, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Sets Enabled. + /// + IProtectiveStopBuilder WithEnabled( + bool enabled, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Binds asynchronous Active reads. + /// + IProtectiveStopBuilder BindActive( + Func> read); + + /// + /// Binds asynchronous Enabled reads. + /// + IProtectiveStopBuilder BindEnabled( + Func> read); + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/SafetyBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/SafetyBuilders.cs new file mode 100644 index 0000000000..ba1d359eaf --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/SafetyBuilders.cs @@ -0,0 +1,351 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal sealed class SafetyStateBuilder : + RoboticsNodeBuilder, + ISafetyStateBuilder + { + public SafetyStateBuilder(RoboticsBuildScope scope, SafetyStateState state) + : base(scope, state) + { + scope.SafetyStates.Add(this); + } + + public ISafetyStateBuilder WithComponentName(string componentName) + { + return WithComponentName(new LocalizedText(componentName)); + } + + public ISafetyStateBuilder WithComponentName(LocalizedText componentName) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetComponentName(State, Scope.Context, componentName); + return this; + } + + public ISafetyStateBuilder WithEmergencyStop( + bool value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + EmergencyStop, + value, + statusCode, + timestamp); + return this; + } + + public ISafetyStateBuilder WithOperationalMode( + OperationalModeEnumeration value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + OperationalMode, + value, + statusCode, + timestamp); + return this; + } + + public ISafetyStateBuilder WithProtectiveStop( + bool value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + ProtectiveStop, + value, + statusCode, + timestamp); + return this; + } + + public ISafetyStateBuilder BindEmergencyStop( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(EmergencyStop, read); + return this; + } + + public ISafetyStateBuilder BindOperationalMode( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(OperationalMode, read); + return this; + } + + public ISafetyStateBuilder BindProtectiveStop( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(ProtectiveStop, read); + return this; + } + + public IEmergencyStopBuilder AddEmergencyStop( + string browseName, + Action? configure = null) + { + return AddEmergencyStop( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IEmergencyStopBuilder AddEmergencyStop( + string browseName, + string name, + Action? configure = null) + { + IEmergencyStopBuilder builder = AddEmergencyStop(browseName); + builder.WithName(name); + configure?.Invoke(builder); + return builder; + } + + public IEmergencyStopBuilder AddEmergencyStop( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + State.AddEmergencyStopFunctions(Scope.Context); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + EmergencyStopFunctionState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + State.EmergencyStopFunctions!, + normalized, + (parent, name) => + Scope.Context.CreateInstanceOfEmergencyStopFunctionType(parent, name)); + var builder = new EmergencyStopBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + public IProtectiveStopBuilder AddProtectiveStop( + string browseName, + Action? configure = null) + { + return AddProtectiveStop( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IProtectiveStopBuilder AddProtectiveStop( + string browseName, + string name, + Action? configure = null) + { + IProtectiveStopBuilder builder = AddProtectiveStop(browseName); + builder.WithName(name); + configure?.Invoke(builder); + return builder; + } + + public IProtectiveStopBuilder AddProtectiveStop( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + State.AddProtectiveStopFunctions(Scope.Context); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + ProtectiveStopFunctionState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + State.ProtectiveStopFunctions!, + normalized, + (parent, name) => + Scope.Context.CreateInstanceOfProtectiveStopFunctionType(parent, name)); + var builder = new ProtectiveStopBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + private BaseObjectState ParameterSet => State.ParameterSet ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory ParameterSet is missing below safety state '{0}'.", + State.BrowseName); + + private BaseDataVariableState EmergencyStop => + RoboticsBuilderUtilities.FindRequiredChild>( + Scope.Context, + ParameterSet, + BrowseNames.EmergencyStop); + + private BaseDataVariableState OperationalMode => + RoboticsBuilderUtilities.FindRequiredChild< + BaseDataVariableState>( + Scope.Context, + ParameterSet, + BrowseNames.OperationalMode); + + private BaseDataVariableState ProtectiveStop => + RoboticsBuilderUtilities.FindRequiredChild>( + Scope.Context, + ParameterSet, + BrowseNames.ProtectiveStop); + } + + internal sealed class EmergencyStopBuilder : + RoboticsNodeBuilder, + IEmergencyStopBuilder + { + public EmergencyStopBuilder( + RoboticsBuildScope scope, + EmergencyStopFunctionState state) + : base(scope, state) + { + scope.EmergencyStops.Add(this); + } + + internal bool HasName { get; private set; } + + public IEmergencyStopBuilder WithName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException( + "A non-empty emergency-stop name is required.", + nameof(name)); + } + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue(State.Name!, name); + HasName = true; + return this; + } + + public IEmergencyStopBuilder WithActive( + bool active, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + State.Active!, + active, + statusCode, + timestamp); + return this; + } + + public IEmergencyStopBuilder BindActive( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(State.Active!, read); + return this; + } + } + + internal sealed class ProtectiveStopBuilder : + RoboticsNodeBuilder, + IProtectiveStopBuilder + { + public ProtectiveStopBuilder( + RoboticsBuildScope scope, + ProtectiveStopFunctionState state) + : base(scope, state) + { + scope.ProtectiveStops.Add(this); + } + + internal bool HasName { get; private set; } + + public IProtectiveStopBuilder WithName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException( + "A non-empty protective-stop name is required.", + nameof(name)); + } + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue(State.Name!, name); + HasName = true; + return this; + } + + public IProtectiveStopBuilder WithActive( + bool active, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + State.Active!, + active, + statusCode, + timestamp); + return this; + } + + public IProtectiveStopBuilder WithEnabled( + bool enabled, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + State.Enabled!, + enabled, + statusCode, + timestamp); + return this; + } + + public IProtectiveStopBuilder BindActive( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(State.Active!, read); + return this; + } + + public IProtectiveStopBuilder BindEnabled( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(State.Enabled!, read); + return this; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/SystemControllerBuilderInterfaces.cs b/src/Opc.Ua.Robotics.Server/Builders/SystemControllerBuilderInterfaces.cs new file mode 100644 index 0000000000..9e10e5d43c --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/SystemControllerBuilderInterfaces.cs @@ -0,0 +1,301 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Di; +using Opc.Ua.Di.Server.Builders; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Builds one below the DI DeviceSet. + /// + public interface IMotionDeviceSystemBuilder : + IRoboticsNodeBuilder + { + /// + /// Sets the optional system component name. + /// + IMotionDeviceSystemBuilder WithComponentName(string componentName); + + /// + /// Sets the optional localized system component name. + /// + IMotionDeviceSystemBuilder WithComponentName(LocalizedText componentName); + + /// + /// Adds a controller to the mandatory Controllers folder. + /// + IControllerBuilder AddController( + string browseName, + Action? configure = null); + + /// + /// Adds a controller to the mandatory Controllers folder. + /// + IControllerBuilder AddController( + QualifiedName browseName, + Action? configure = null); + + /// + /// Adds a motion device to the mandatory MotionDevices folder. + /// + IMotionDeviceBuilder AddMotionDevice( + string browseName, + Action? configure = null); + + /// + /// Adds a motion device to the mandatory MotionDevices folder. + /// + IMotionDeviceBuilder AddMotionDevice( + QualifiedName browseName, + Action? configure = null); + + /// + /// Adds a safety-state instance to the mandatory SafetyStates folder. + /// + ISafetyStateBuilder AddSafetyState( + string browseName, + Action? configure = null); + + /// + /// Adds a safety-state instance to the mandatory SafetyStates folder. + /// + ISafetyStateBuilder AddSafetyState( + QualifiedName browseName, + Action? configure = null); + } + + /// + /// Builds a Robotics controller and its contained components. + /// + public interface IControllerBuilder : IRoboticsNodeBuilder + { + /// + /// Sets inherited DI identification fields. + /// + IControllerBuilder WithIdentification(Action configure); + + /// + /// Sets the optional controller component name. + /// + IControllerBuilder WithComponentName(string componentName); + + /// + /// Sets the optional localized controller component name. + /// + IControllerBuilder WithComponentName(LocalizedText componentName); + + + /// + /// Adds the optional standard SystemOperation facet. + /// + ISystemOperationBuilder AddSystemOperation( + Action? configure = null); + + /// + /// Adds the optional standard Programs directory and backs it with a + /// file-system provider. The directory is a Part 5 + /// FileDirectoryType, so a client reads and writes the + /// controller's programs with the standard file services. + /// + /// + /// Selects the provider and the binding options. + /// + IProgramsBuilder AddPrograms(Action configure); + + /// + /// Configures the mandatory CurrentUser child. + /// + IControllerBuilder WithCurrentUser(Action configure); + + /// + /// Adds software to the mandatory Software folder. + /// + IRoboticsSoftwareBuilder AddSoftware( + string browseName, + Action? configure = null); + + /// + /// Adds software to the mandatory Software folder. + /// + IRoboticsSoftwareBuilder AddSoftware( + QualifiedName browseName, + Action? configure = null); + + /// + /// Adds a task control to the mandatory TaskControls folder. + /// + ITaskControlBuilder AddTaskControl( + string browseName, + Action? configure = null); + + /// + /// Adds a task control to the mandatory TaskControls folder. + /// + ITaskControlBuilder AddTaskControl( + QualifiedName browseName, + Action? configure = null); + + /// + /// Adds an auxiliary component to the optional Components folder. + /// + IAuxiliaryComponentBuilder AddAuxiliaryComponent( + string browseName, + Action? configure = null); + + /// + /// Adds an auxiliary component to the optional Components folder. + /// + IAuxiliaryComponentBuilder AddAuxiliaryComponent( + QualifiedName browseName, + Action? configure = null); + + /// + /// Adds a drive to the optional Components folder. + /// + IDriveBuilder AddDrive( + string browseName, + Action? configure = null); + + /// + /// Adds a drive to the optional Components folder. + /// + IDriveBuilder AddDrive( + QualifiedName browseName, + Action? configure = null); + + /// + /// Adds the optional standard Controls relationship to a motion device. + /// + IControllerBuilder Controls(IMotionDeviceBuilder motionDevice); + + /// + /// Adds the standard HasSafetyStates relationship. + /// + IControllerBuilder UsesSafetyState(ISafetyStateBuilder safetyState); + + /// + /// Adds a symmetric Robotics IsConnectedTo relationship. + /// + /// The generated target state type. + IControllerBuilder IsConnectedTo(IRoboticsNodeBuilder other) + where TState : NodeState; + } + + + /// + /// Builds a standard Robotics user descriptor. + /// + public interface IRoboticsUserBuilder : IRoboticsNodeBuilder + { + /// + /// Sets the mandatory user level. + /// + IRoboticsUserBuilder WithLevel(string level); + + /// + /// Sets the optional user name. + /// + IRoboticsUserBuilder WithName(string name); + } + + /// + /// Builds the standard ControllerType SystemOperation facet. + /// + public interface ISystemOperationBuilder : IRoboticsNodeBuilder + { + /// + /// Sets the initial operation state. + /// + ISystemOperationBuilder WithInitialState(RoboticsOperationState state); + + /// + /// Registers the optional GetReady method handler. + /// + ISystemOperationBuilder OnGetReady( + Func> handler); + + /// + /// Registers the optional Start method handler. + /// + ISystemOperationBuilder OnStart( + Func> handler); + + /// + /// Registers the optional Stop method handler. + /// + ISystemOperationBuilder OnStop( + Func> handler); + + /// + /// Registers the optional StandDown method handler. + /// + ISystemOperationBuilder OnStandDown( + Func> handler); + + /// + /// Sets possible and default stop modes. + /// + ISystemOperationBuilder WithStopModes( + ArrayOf modes, + RoboticsStopMode defaultMode); + + /// + /// Registers a transition notification handler. + /// + ISystemOperationBuilder OnTransition( + Func handler); + + /// + /// Sets the LastTransitionReason value written on every move. + /// + ISystemOperationBuilder WithTransitionReason(short reason); + } + + /// + /// Builds one DI SoftwareType instance below a Robotics controller. + /// + public interface IRoboticsSoftwareBuilder : IRoboticsNodeBuilder + { + /// + /// Sets inherited DI identification fields. + /// + IRoboticsSoftwareBuilder WithIdentification( + Action configure); + + /// + /// Applies focused low-level state configuration before registration. + /// + new IRoboticsSoftwareBuilder Configure( + Action configure); + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/SystemControllerBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/SystemControllerBuilders.cs new file mode 100644 index 0000000000..4c732c6653 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/SystemControllerBuilders.cs @@ -0,0 +1,431 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Di; +using Opc.Ua.Di.Server.Builders; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal sealed class MotionDeviceSystemBuilder : + RoboticsNodeBuilder, + IMotionDeviceSystemBuilder + { + public MotionDeviceSystemBuilder( + RoboticsBuildScope scope, + MotionDeviceSystemState state) + : base(scope, state) + { + } + + public IMotionDeviceSystemBuilder WithComponentName(string componentName) + { + return WithComponentName(new LocalizedText(componentName)); + } + + public IMotionDeviceSystemBuilder WithComponentName(LocalizedText componentName) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetComponentName(State, Scope.Context, componentName); + return this; + } + + public IControllerBuilder AddController( + string browseName, + Action? configure = null) + { + return AddController( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IControllerBuilder AddController( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + FolderState parent = State.Controllers ?? + throw MissingMandatoryContainer(BrowseNames.Controllers); + ControllerState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + parent, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfControllerType(container, name)); + var builder = new ControllerBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + public IMotionDeviceBuilder AddMotionDevice( + string browseName, + Action? configure = null) + { + return AddMotionDevice( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IMotionDeviceBuilder AddMotionDevice( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + FolderState parent = State.MotionDevices ?? + throw MissingMandatoryContainer(BrowseNames.MotionDevices); + MotionDeviceState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + parent, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfMotionDeviceType(container, name)); + var builder = new MotionDeviceBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + public ISafetyStateBuilder AddSafetyState( + string browseName, + Action? configure = null) + { + return AddSafetyState( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public ISafetyStateBuilder AddSafetyState( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + FolderState parent = State.SafetyStates ?? + throw MissingMandatoryContainer(BrowseNames.SafetyStates); + SafetyStateState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + parent, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfSafetyStateType(container, name)); + var builder = new SafetyStateBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + private ServiceResultException MissingMandatoryContainer(string browseName) + { + return ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory container '{0}' is missing below '{1}'.", + browseName, + State.BrowseName); + } + } + + internal sealed class ControllerBuilder : + RoboticsNodeBuilder, + IControllerBuilder + { + public ControllerBuilder(RoboticsBuildScope scope, ControllerState state) + : base(scope, state) + { + scope.Controllers.Add(this); + } + + internal List Software { get; } = []; + + internal List TaskControls { get; } = []; + + internal SystemOperationBuilder? SystemOperation { get; private set; } + + internal ProgramsBuilder? Programs { get; private set; } + + public IControllerBuilder WithIdentification( + Action configure) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.ApplyIdentification( + State, + Scope.Context, + configure); + return this; + } + + public IControllerBuilder WithComponentName(string componentName) + { + return WithComponentName(new LocalizedText(componentName)); + } + + public IControllerBuilder WithComponentName(LocalizedText componentName) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetComponentName(State, Scope.Context, componentName); + return this; + } + + public ISystemOperationBuilder AddSystemOperation( + Action? configure = null) + { + Scope.EnsureMutable(); + State.AddSystemOperation(Scope.Context); + SystemOperation ??= new SystemOperationBuilder(Scope, State.SystemOperation!); + configure?.Invoke(SystemOperation); + return SystemOperation; + } + + public IProgramsBuilder AddPrograms(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + Scope.EnsureMutable(); + State.AddPrograms(Scope.Context); + Programs ??= new ProgramsBuilder( + Scope, + State.Programs ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated Programs directory is missing below controller '{0}'.", + State.BrowseName)); + configure(Programs); + Programs.Schedule(); + return Programs; + } + + public IControllerBuilder WithCurrentUser(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + Scope.EnsureMutable(); + UserState currentUser = State.CurrentUser ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory CurrentUser is missing below controller '{0}'.", + State.BrowseName); + configure(new RoboticsUserBuilder(Scope, currentUser)); + return this; + } + + public IRoboticsSoftwareBuilder AddSoftware( + string browseName, + Action? configure = null) + { + return AddSoftware( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IRoboticsSoftwareBuilder AddSoftware( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + FolderState parent = State.Software ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory Software container is missing below '{0}'.", + State.BrowseName); + SoftwareState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + parent, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfSoftwareType(container, name)); + var builder = new RoboticsSoftwareBuilder(Scope, state); + Software.Add(builder); + configure?.Invoke(builder); + return builder; + } + + public ITaskControlBuilder AddTaskControl( + string browseName, + Action? configure = null) + { + return AddTaskControl( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public ITaskControlBuilder AddTaskControl( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + FolderState parent = State.TaskControls ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory TaskControls container is missing below '{0}'.", + State.BrowseName); + TaskControlState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + parent, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfTaskControlType(container, name)); + var builder = new TaskControlBuilder(Scope, state); + TaskControls.Add(builder); + configure?.Invoke(builder); + return builder; + } + + public IAuxiliaryComponentBuilder AddAuxiliaryComponent( + string browseName, + Action? configure = null) + { + return AddAuxiliaryComponent( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IAuxiliaryComponentBuilder AddAuxiliaryComponent( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + State.AddComponents(Scope.Context); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + AuxiliaryComponentState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + State.Components!, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfAuxiliaryComponentType(container, name)); + var builder = new AuxiliaryComponentBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + public IDriveBuilder AddDrive( + string browseName, + Action? configure = null) + { + return AddDrive( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public IDriveBuilder AddDrive( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + State.AddComponents(Scope.Context); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + DriveState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + State.Components!, + normalized, + (container, name) => + Scope.Context.CreateInstanceOfDriveType(container, name)); + var builder = new DriveBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + public IControllerBuilder Controls(IMotionDeviceBuilder motionDevice) + { + MotionDeviceBuilder target = + RequireSameScope( + motionDevice, + nameof(motionDevice)); + Scope.AddSemanticReference( + RoboticsSemanticReference.Controls, + this, + target); + return this; + } + + public IControllerBuilder UsesSafetyState(ISafetyStateBuilder safetyState) + { + SafetyStateBuilder target = + RequireSameScope( + safetyState, + nameof(safetyState)); + Scope.AddSemanticReference( + RoboticsSemanticReference.HasSafetyStates, + this, + target); + return this; + } + + public IControllerBuilder IsConnectedTo( + IRoboticsNodeBuilder other) + where TState : NodeState + { + return IsConnectedTo(other); + } + } + + internal sealed class RoboticsSoftwareBuilder : + RoboticsNodeBuilder, + IRoboticsSoftwareBuilder + { + public RoboticsSoftwareBuilder( + RoboticsBuildScope scope, + SoftwareState state) + : base(scope, state) + { + } + + public IRoboticsSoftwareBuilder WithIdentification( + Action configure) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.ApplyIdentification( + State, + Scope.Context, + configure); + return this; + } + + public new IRoboticsSoftwareBuilder Configure( + Action configure) + { + base.Configure(configure); + return this; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/TaskControlBuilderInterfaces.cs b/src/Opc.Ua.Robotics.Server/Builders/TaskControlBuilderInterfaces.cs new file mode 100644 index 0000000000..7db33e4559 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/TaskControlBuilderInterfaces.cs @@ -0,0 +1,208 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Robotics.Server.Builders +{ + /// + /// Builds a task control without the operation state machine. + /// + public interface ITaskControlBuilder : IRoboticsNodeBuilder + { + /// + /// Sets the mandatory component name. + /// + ITaskControlBuilder WithComponentName(string componentName); + + /// + /// Sets the mandatory localized component name. + /// + ITaskControlBuilder WithComponentName(LocalizedText componentName); + + /// + /// Materializes and sets optional ExecutionMode. + /// + ITaskControlBuilder WithExecutionMode( + ExecutionModeEnumeration value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Sets mandatory TaskProgramLoaded. + /// + ITaskControlBuilder WithTaskProgramLoaded( + bool value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Sets mandatory TaskProgramName. + /// + ITaskControlBuilder WithTaskProgramName( + string value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default); + + /// + /// Materializes ExecutionMode and binds asynchronous reads. + /// + ITaskControlBuilder BindExecutionMode( + Func> read); + + /// + /// Binds asynchronous TaskProgramLoaded reads. + /// + ITaskControlBuilder BindTaskProgramLoaded( + Func> read); + + /// + /// Binds asynchronous TaskProgramName reads. + /// + ITaskControlBuilder BindTaskProgramName( + Func> read); + + + /// + /// Adds the optional standard TaskControlOperation facet. + /// + ITaskControlOperationBuilder AddTaskControlOperation( + Action? configure = null); + + /// + /// Adds a task module to the optional TaskModules folder. + /// + ITaskModuleBuilder AddTaskModule( + string browseName, + Action? configure = null); + + + /// + /// Adds a task module to the optional TaskModules folder. + /// + ITaskModuleBuilder AddTaskModule( + QualifiedName browseName, + Action? configure = null); + + /// + /// Adds the standard Controls relationship to a motion device. + /// + ITaskControlBuilder Controls(IMotionDeviceBuilder motionDevice); + } + + + /// + /// Builds the standard TaskControlType TaskControlOperation facet. + /// + public interface ITaskControlOperationBuilder : IRoboticsNodeBuilder + { + /// + /// Registers the optional Start method handler. + /// + ITaskControlOperationBuilder OnStart( + Func> handler); + + /// + /// Registers the optional Stop method handler. + /// + ITaskControlOperationBuilder OnStop( + Func> handler); + + /// + /// Registers the optional LoadByName method handler. + /// + ITaskControlOperationBuilder OnLoadByName( + Func> handler); + + /// + /// Registers the optional LoadByNodeId method handler. + /// + ITaskControlOperationBuilder OnLoadByNodeId( + Func> handler); + + /// + /// Registers the optional UnloadByName method handler. + /// + ITaskControlOperationBuilder OnUnloadByName( + Func> handler); + + /// + /// Registers the optional UnloadByNodeId method handler. + /// + ITaskControlOperationBuilder OnUnloadByNodeId( + Func> handler); + + /// + /// Registers the optional UnloadProgram method handler. + /// + ITaskControlOperationBuilder OnUnloadProgram( + Func> handler); + + /// + /// Registers the optional ResetToProgramStart method handler. + /// + ITaskControlOperationBuilder OnResetToProgramStart( + Func> handler); + + /// + /// Sets the optional MotionDevicesUnderControl property. + /// + ITaskControlOperationBuilder WithMotionDevicesUnderControl( + ArrayOf motionDevices); + + /// + /// Registers a transition notification handler. + /// + ITaskControlOperationBuilder OnTransition( + Func handler); + } + + /// + /// Builds a task module. + /// + public interface ITaskModuleBuilder : IRoboticsNodeBuilder + { + /// + /// Sets the required task-module name. + /// + ITaskModuleBuilder WithName(string name); + + /// + /// Materializes and sets Version. + /// + ITaskModuleBuilder WithVersion(string version); + + /// + /// Materializes and sets IsReferenced. + /// + ITaskModuleBuilder WithIsReferenced(bool isReferenced); + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/TaskControlBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/TaskControlBuilders.cs new file mode 100644 index 0000000000..1dbe91418e --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/TaskControlBuilders.cs @@ -0,0 +1,268 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal sealed class TaskControlBuilder : + RoboticsNodeBuilder, + ITaskControlBuilder + { + public TaskControlBuilder(RoboticsBuildScope scope, TaskControlState state) + : base(scope, state) + { + } + + internal TaskControlOperationBuilder? TaskControlOperation { get; private set; } + + public ITaskControlBuilder WithComponentName(string componentName) + { + return WithComponentName(new LocalizedText(componentName)); + } + + public ITaskControlBuilder WithComponentName(LocalizedText componentName) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetComponentName(State, Scope.Context, componentName); + return this; + } + + public ITaskControlBuilder WithExecutionMode( + ExecutionModeEnumeration value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + EnsureExecutionMode(), + value, + statusCode, + timestamp); + return this; + } + + public ITaskControlBuilder WithTaskProgramLoaded( + bool value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + TaskProgramLoaded, + value, + statusCode, + timestamp); + return this; + } + + public ITaskControlBuilder WithTaskProgramName( + string value, + StatusCode statusCode = default, + DateTimeUtc timestamp = default) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue( + TaskProgramName, + value, + statusCode, + timestamp); + return this; + } + + public ITaskControlBuilder BindExecutionMode( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(EnsureExecutionMode(), read); + return this; + } + + public ITaskControlBuilder BindTaskProgramLoaded( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(TaskProgramLoaded, read); + return this; + } + + public ITaskControlBuilder BindTaskProgramName( + Func> read) + { + Scope.EnsureMutable(); + RoboticsBuilderUtilities.BindRead(TaskProgramName, read); + return this; + } + + public ITaskModuleBuilder AddTaskModule( + string browseName, + Action? configure = null) + { + return AddTaskModule( + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName), + configure); + } + + public ITaskControlOperationBuilder AddTaskControlOperation( + Action? configure = null) + { + Scope.EnsureMutable(); + State.AddTaskControlOperation(Scope.Context); + TaskControlOperation ??= new TaskControlOperationBuilder( + Scope, + State.TaskControlOperation!); + configure?.Invoke(TaskControlOperation); + return TaskControlOperation; + } + + public ITaskModuleBuilder AddTaskModule( + QualifiedName browseName, + Action? configure = null) + { + Scope.EnsureMutable(); + State.AddTaskModules(Scope.Context); + QualifiedName normalized = + RoboticsBuilderUtilities.NormalizeBrowseName(BuildContext, browseName); + TaskModuleState state = RoboticsBuilderUtilities.AddContained( + Scope.Context, + State.TaskModules!, + normalized, + global::Opc.Ua.ReferenceTypeIds.Organizes, + (parent, name) => + Scope.Context.CreateInstanceOfTaskModuleType(parent, name)); + var builder = new TaskModuleBuilder(Scope, state); + configure?.Invoke(builder); + return builder; + } + + public ITaskControlBuilder Controls(IMotionDeviceBuilder motionDevice) + { + MotionDeviceBuilder target = + RequireSameScope( + motionDevice, + nameof(motionDevice)); + Scope.AddTaskControlRelation(this, target); + return this; + } + + private BaseObjectState ParameterSet => State.ParameterSet ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated mandatory ParameterSet is missing below task control '{0}'.", + State.BrowseName); + + private BaseDataVariableState TaskProgramLoaded => + RoboticsBuilderUtilities.FindRequiredChild>( + Scope.Context, + ParameterSet, + BrowseNames.TaskProgramLoaded); + + private BaseDataVariableState TaskProgramName => + RoboticsBuilderUtilities.FindRequiredChild>( + Scope.Context, + ParameterSet, + BrowseNames.TaskProgramName); + + private BaseDataVariableState EnsureExecutionMode() + { + BaseDataVariableState? variable = + RoboticsBuilderUtilities.FindChild< + BaseDataVariableState>( + Scope.Context, + ParameterSet, + BrowseNames.ExecutionMode); + if (variable == null) + { + variable = RoboticsBuilderUtilities.AddGeneratedChild( + Scope.Context, + ParameterSet, + parent => + OpcUaRoboticsExtensions + .CreateTaskControlType_ParameterSet_ExecutionMode( + Scope.Context, + parent, + true)); + } + return variable; + } + } + + internal sealed class TaskModuleBuilder : + RoboticsNodeBuilder, + ITaskModuleBuilder + { + public TaskModuleBuilder(RoboticsBuildScope scope, TaskModuleState state) + : base(scope, state) + { + scope.TaskModules.Add(this); + } + + internal bool HasName { get; private set; } + + public ITaskModuleBuilder WithName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException( + "A non-empty task-module name is required.", + nameof(name)); + } + Scope.EnsureMutable(); + RoboticsBuilderUtilities.SetValue(State.Name!, name); + HasName = true; + return this; + } + + public ITaskModuleBuilder WithVersion(string version) + { + if (version == null) + { + throw new ArgumentNullException(nameof(version)); + } + Scope.EnsureMutable(); + State.AddVersion(Scope.Context); + RoboticsBuilderUtilities.SetValue(State.Version!, version); + return this; + } + + public ITaskModuleBuilder WithIsReferenced(bool isReferenced) + { + Scope.EnsureMutable(); + State.AddIsReferenced(Scope.Context); + RoboticsBuilderUtilities.SetValue(State.IsReferenced!, isReferenced); + return this; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Builders/TaskControlOperationBuilders.cs b/src/Opc.Ua.Robotics.Server/Builders/TaskControlOperationBuilders.cs new file mode 100644 index 0000000000..d0221cddee --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Builders/TaskControlOperationBuilders.cs @@ -0,0 +1,361 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server.StateMachines; + +namespace Opc.Ua.Robotics.Server.Builders +{ + internal sealed class TaskControlOperationBuilder : + RoboticsOperationBuilder, + ITaskControlOperationBuilder + { + private static readonly ArrayOf s_states = + [ + new( + TaskControlStateMachineTypeIds.StateIds.Idle, + TaskControlStateMachineTypeIds.StateNumbers.Idle, + "Idle"), + new( + TaskControlStateMachineTypeIds.StateIds.Ready, + TaskControlStateMachineTypeIds.StateNumbers.Ready, + "Ready"), + new( + TaskControlStateMachineTypeIds.StateIds.Executing, + TaskControlStateMachineTypeIds.StateNumbers.Executing, + "Executing") + ]; + + private static readonly ArrayOf s_transitions = + [ + new( + TaskControlStateMachineTypeIds.TransitionIds.IdleToIdle, + TaskControlStateMachineTypeIds.TransitionNumbers.IdleToIdle, + "IdleToIdle"), + new( + TaskControlStateMachineTypeIds.TransitionIds.IdleToReady, + TaskControlStateMachineTypeIds.TransitionNumbers.IdleToReady, + "IdleToReady"), + new( + TaskControlStateMachineTypeIds.TransitionIds.ReadyToIdle, + TaskControlStateMachineTypeIds.TransitionNumbers.ReadyToIdle, + "ReadyToIdle"), + new( + TaskControlStateMachineTypeIds.TransitionIds.ReadyToExecuting, + TaskControlStateMachineTypeIds.TransitionNumbers.ReadyToExecuting, + "ReadyToExecuting"), + new( + TaskControlStateMachineTypeIds.TransitionIds.ExecutingToReady, + TaskControlStateMachineTypeIds.TransitionNumbers.ExecutingToReady, + "ExecutingToReady"), + new( + TaskControlStateMachineTypeIds.TransitionIds.ExecutingToIdle, + TaskControlStateMachineTypeIds.TransitionNumbers.ExecutingToIdle, + "ExecutingToIdle") + ]; + + public TaskControlOperationBuilder(RoboticsBuildScope scope, TaskControlOperationState state) + : base( + scope, + state, + state.TaskControlStateMachine ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Generated TaskControlStateMachine is missing below '{0}'.", + state.BrowseName), + s_states, + s_transitions) + { + } + + public ITaskControlOperationBuilder OnStart( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddStart(Scope.Context); + Machine.Start!.OnCallAsync = async (_, _, _, cancellationToken) => + { + ServiceResult result = await InvokeOperationAsync( + RoboticsOperationState.Ready, + RoboticsOperationState.Executing, + TaskControlStateMachineTypeIds.TransitionIds.ReadyToExecuting, + handler, + cancellationToken).ConfigureAwait(false); + return new StartMethodStateResult + { + ServiceResult = result, + Status = StatusFrom(result) + }; + }; + return this; + } + + public ITaskControlOperationBuilder OnStop( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddStop(Scope.Context); + Machine.Stop!.OnCallAsync = async (_, _, _, stopMode, cancellationToken) => + { + ServiceResult result = await InvokeStopAsync(stopMode, handler, cancellationToken) + .ConfigureAwait(false); + return new StopMethodStateResult + { + ServiceResult = result, + Status = StatusFrom(result) + }; + }; + return this; + } + + public ITaskControlOperationBuilder OnLoadByName( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddLoadByName(Scope.Context); + Machine.LoadByName!.OnCallAsync = async (_, _, _, name, cancellationToken) => + { + RoboticsProgramResult result = await InvokeProgramAsync( + RoboticsOperationState.Idle, + RoboticsOperationState.Ready, + TaskControlStateMachineTypeIds.TransitionIds.IdleToReady, + ct => handler(name, ct), + cancellationToken).ConfigureAwait(false); + return new LoadByNameMethodStateResult + { + ServiceResult = result.ServiceResult, + Status = result.Status + }; + }; + return this; + } + + public ITaskControlOperationBuilder OnLoadByNodeId( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddLoadByNodeId(Scope.Context); + Machine.LoadByNodeId!.OnCallAsync = async (_, _, _, id, cancellationToken) => + { + NodeId nodeId = ExpandedNodeId.ToNodeId(id, Scope.Context.NamespaceUris); + RoboticsProgramResult result = await InvokeProgramAsync( + RoboticsOperationState.Idle, + RoboticsOperationState.Ready, + TaskControlStateMachineTypeIds.TransitionIds.IdleToReady, + ct => handler(nodeId, ct), + cancellationToken).ConfigureAwait(false); + return new LoadByNodeIdMethodStateResult + { + ServiceResult = result.ServiceResult, + Status = result.Status + }; + }; + return this; + } + + public ITaskControlOperationBuilder OnUnloadByName( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddUnloadByName(Scope.Context); + Machine.UnloadByName!.OnCallAsync = async (_, _, _, name, cancellationToken) => + { + RoboticsProgramResult result = await InvokeProgramAsync( + RoboticsOperationState.Ready, + RoboticsOperationState.Idle, + TaskControlStateMachineTypeIds.TransitionIds.ReadyToIdle, + ct => handler(name, ct), + cancellationToken).ConfigureAwait(false); + return new UnloadByNameMethodStateResult + { + ServiceResult = result.ServiceResult, + Status = result.Status + }; + }; + return this; + } + + public ITaskControlOperationBuilder OnUnloadByNodeId( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddUnloadByNodeId(Scope.Context); + Machine.UnloadByNodeId!.OnCallAsync = async (_, _, _, id, cancellationToken) => + { + NodeId nodeId = ExpandedNodeId.ToNodeId(id, Scope.Context.NamespaceUris); + RoboticsProgramResult result = await InvokeProgramAsync( + RoboticsOperationState.Ready, + RoboticsOperationState.Idle, + TaskControlStateMachineTypeIds.TransitionIds.ReadyToIdle, + ct => handler(nodeId, ct), + cancellationToken).ConfigureAwait(false); + return new UnloadByNodeIdMethodStateResult + { + ServiceResult = result.ServiceResult, + Status = result.Status + }; + }; + return this; + } + + public ITaskControlOperationBuilder OnUnloadProgram( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddUnloadProgram(Scope.Context); + Machine.UnloadProgram!.OnCallAsync = async (_, _, _, cancellationToken) => + { + RoboticsProgramResult result = await InvokeProgramAsync( + RoboticsOperationState.Ready, + RoboticsOperationState.Idle, + TaskControlStateMachineTypeIds.TransitionIds.ReadyToIdle, + handler, + cancellationToken).ConfigureAwait(false); + return new UnloadProgramMethodStateResult + { + ServiceResult = result.ServiceResult, + Status = result.Status + }; + }; + return this; + } + + public ITaskControlOperationBuilder OnResetToProgramStart( + Func> handler) + { + if (handler == null) + { + throw new ArgumentNullException(nameof(handler)); + } + + Scope.EnsureMutable(); + Machine.AddReadySubstateMachine(Scope.Context); + Machine.ReadySubstateMachine!.AddResetToProgramStart(Scope.Context); + Machine.ReadySubstateMachine.ResetToProgramStart!.OnCallAsync = async (_, _, _, cancellationToken) => + { + RoboticsProgramResult result = await handler(cancellationToken).ConfigureAwait(false); + return new ResetToProgramStartMethodStateResult + { + ServiceResult = result.ServiceResult, + Status = result.Status + }; + }; + return this; + } + + public ITaskControlOperationBuilder WithMotionDevicesUnderControl( + ArrayOf motionDevices) + { + Scope.EnsureMutable(); + State.AddMotionDevicesUnderControl(Scope.Context); + RoboticsBuilderUtilities.SetValue(State.MotionDevicesUnderControl!, motionDevices); + return this; + } + + public ITaskControlOperationBuilder OnTransition( + Func handler) + { + SetTransitionHandler(handler); + return this; + } + + protected override uint StateId(RoboticsOperationState state) + { + return state switch + { + RoboticsOperationState.Idle => TaskControlStateMachineTypeIds.StateIds.Idle, + RoboticsOperationState.Ready => TaskControlStateMachineTypeIds.StateIds.Ready, + RoboticsOperationState.Executing => TaskControlStateMachineTypeIds.StateIds.Executing, + _ => throw new ArgumentOutOfRangeException(nameof(state)) + }; + } + + protected override RoboticsOperationState StateFromId(uint stateId) + { + return stateId switch + { + TaskControlStateMachineTypeIds.StateIds.Idle => RoboticsOperationState.Idle, + TaskControlStateMachineTypeIds.StateIds.Ready => RoboticsOperationState.Ready, + TaskControlStateMachineTypeIds.StateIds.Executing => RoboticsOperationState.Executing, + _ => throw new ArgumentOutOfRangeException(nameof(stateId)) + }; + } + + protected override uint TransitionId( + RoboticsOperationState fromState, + RoboticsOperationState toState) + { + return (fromState, toState) switch + { + (RoboticsOperationState.Ready, RoboticsOperationState.Executing) => + TaskControlStateMachineTypeIds.TransitionIds.ReadyToExecuting, + (RoboticsOperationState.Executing, RoboticsOperationState.Ready) => + TaskControlStateMachineTypeIds.TransitionIds.ExecutingToReady, + _ => throw new ArgumentOutOfRangeException(nameof(toState)) + }; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/DiNodeManagerRoboticsExtensions.cs b/src/Opc.Ua.Robotics.Server/DiNodeManagerRoboticsExtensions.cs new file mode 100644 index 0000000000..a0870e4432 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/DiNodeManagerRoboticsExtensions.cs @@ -0,0 +1,57 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using Opc.Ua.Di.Server; + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Direct Robotics configuration helpers for custom DI node managers. + /// + public static class DiNodeManagerRoboticsExtensions + { + /// + /// Creates a Robotics build context after validating that the custom + /// manager exposes the required DI and Robotics model nodes. + /// + public static IRoboticsBuildContext CreateRoboticsBuildContext( + this DiNodeManager manager, + RoboticsServerOptions options, + CancellationToken cancellationToken = default) + { + if (manager == null) + { + throw new ArgumentNullException(nameof(manager)); + } + return new RoboticsBuildContext(manager, options, cancellationToken); + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/Hosting/OpcUaServerRoboticsBuilderExtensions.cs b/src/Opc.Ua.Robotics.Server/Hosting/OpcUaServerRoboticsBuilderExtensions.cs new file mode 100644 index 0000000000..cf3096f042 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Hosting/OpcUaServerRoboticsBuilderExtensions.cs @@ -0,0 +1,386 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Opc.Ua.Di.Server; +using Opc.Ua.Di.Server.Hosting; +using Opc.Ua.Robotics.Server; +using Opc.Ua.Server.Hosting; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Fluent hosting extensions for the OPC UA Robotics server model. + /// + public static class OpcUaServerRoboticsBuilderExtensions + { + /// + /// Registers the stock Robotics node manager, built-in model provider, + /// and Robotics configuration pipeline. + /// + public static IOpcUaServerBuilder AddRobotics( + this IOpcUaServerBuilder builder, + Action? configure = null) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + ClaimDiAddressSpace(builder.Services); + builder.Services.AddOptions(); + if (configure != null) + { + builder.Services.Configure(configure); + } + + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + EnsureAggregatePipeline(builder); + + builder.Services.AddSingleton(static services => + { + RoboticsServerOptions options = services + .GetRequiredService>() + .Value; + IRoboticsModelProvider[] providers = services + .GetServices() + .ToArray(); + IDiPostSetupRunner? runner = services.GetService(); + return new RoboticsNodeManagerFactory(providers, options, runner); + }); + builder.Services.AddSingleton(static services => + new OpcUaServerNodeManagerRegistration( + services.GetRequiredService())); + return builder; + } + + /// + /// Registers an additional compiled Robotics model provider. + /// + /// + /// The compiled model provider type. + /// + public static IOpcUaServerBuilder AddRoboticsModel< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>( + this IOpcUaServerBuilder builder) + where TProvider : class, IRoboticsModelProvider + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + return builder; + } + + /// + /// Registers a synchronous configurator for the stock Robotics manager. + /// + public static IOpcUaServerBuilder ConfigureRobotics( + this IOpcUaServerBuilder builder, + Action configure) + { + return builder.ConfigureRoboticsFor(configure); + } + + /// + /// Registers a one-parameter asynchronous configurator for the stock + /// Robotics manager. The returned task is awaited during server startup. + /// + public static IOpcUaServerBuilder ConfigureRobotics( + this IOpcUaServerBuilder builder, + Func configure) + { + return builder.ConfigureRoboticsFor(configure); + } + + /// + /// Registers an asynchronous configurator for the stock Robotics manager. + /// + public static IOpcUaServerBuilder ConfigureRobotics( + this IOpcUaServerBuilder builder, + Func configure) + { + return builder.ConfigureRoboticsFor(configure); + } + + /// + /// Registers a class-based configurator for the stock Robotics manager. + /// + /// + /// The Robotics configurator type. + /// + public static IOpcUaServerBuilder ConfigureRobotics< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + TConfigurator>( + this IOpcUaServerBuilder builder) + where TConfigurator : class, IRoboticsConfigurator + { + return builder.ConfigureRoboticsFor(); + } + + /// + /// Registers a synchronous configurator for an exact custom manager type. + /// + /// + /// The exact custom DI node manager type. + /// + public static IOpcUaServerBuilder ConfigureRoboticsFor( + this IOpcUaServerBuilder builder, + Action configure) + where TNodeManager : DiNodeManager + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + return builder.ConfigureRoboticsFor((context, _) => + { + configure(context); + return default; + }); + } + + /// + /// Registers a one-parameter asynchronous configurator for an exact custom + /// manager type. The returned task is awaited during server startup. + /// + /// + /// The exact custom DI node manager type. + /// + public static IOpcUaServerBuilder ConfigureRoboticsFor( + this IOpcUaServerBuilder builder, + Func configure) + where TNodeManager : DiNodeManager + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + return builder.ConfigureRoboticsFor( + (context, _) => configure(context)); + } + + /// + /// Registers an asynchronous configurator for an exact custom manager type. + /// + /// + /// The exact custom DI node manager type. + /// + public static IOpcUaServerBuilder ConfigureRoboticsFor( + this IOpcUaServerBuilder builder, + Func configure) + where TNodeManager : DiNodeManager + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + EnsureOptionsRegistered(builder.Services); + EnsureAggregatePipeline(builder); + builder.Services.AddSingleton( + new DelegateRoboticsConfigurationRegistration( + typeof(TNodeManager), + configure)); + return builder; + } + + /// + /// Registers a class-based configurator for an exact custom manager type. + /// + /// + /// The exact custom DI node manager type. + /// + /// + /// The Robotics configurator type. + /// + public static IOpcUaServerBuilder ConfigureRoboticsFor< + TNodeManager, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + TConfigurator>( + this IOpcUaServerBuilder builder) + where TNodeManager : DiNodeManager + where TConfigurator : class, IRoboticsConfigurator + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + EnsureOptionsRegistered(builder.Services); + EnsureAggregatePipeline(builder); + builder.Services.TryAddSingleton(); + builder.Services.AddSingleton( + new ClassRoboticsConfigurationRegistration( + typeof(TNodeManager))); + return builder; + } + + private static void EnsureAggregatePipeline( + IOpcUaServerBuilder builder) + where TNodeManager : DiNodeManager + { + foreach (ServiceDescriptor descriptor in builder.Services) + { + if (descriptor.ServiceType == typeof(RoboticsPipelineMarker)) + { + return; + } + } + + builder.Services.AddSingleton(new RoboticsPipelineMarker()); + builder.ConfigureDevicesFor( + RunRoboticsConfigurationsAsync); + } + + private static void EnsureOptionsRegistered(IServiceCollection services) + { + services.AddOptions(); + } + + private static async ValueTask RunRoboticsConfigurationsAsync( + IDiPostSetupContext postSetupContext) + where TNodeManager : DiNodeManager + { + if (postSetupContext.Manager.GetType() != typeof(TNodeManager)) + { + return; + } + + RoboticsServerOptions options = postSetupContext + .GetRequiredService>() + .Value; + var context = new RoboticsBuildContext( + postSetupContext.Manager, + options, + postSetupContext.CancellationToken, + postSetupContext); + IEnumerable registrations = + postSetupContext.GetRequiredService< + IEnumerable>(); + + foreach (IRoboticsConfigurationRegistration registration in registrations) + { + if (registration.TargetManagerType == typeof(TNodeManager)) + { + await registration.ConfigureAsync(context).ConfigureAwait(false); + } + } + context.Seal(); + } + + private static void ClaimDiAddressSpace(IServiceCollection services) + { + foreach (ServiceDescriptor descriptor in services) + { + if (descriptor.ServiceType == typeof(DiAddressSpaceOwnership)) + { + string ownerName = + (descriptor.ImplementationInstance as DiAddressSpaceOwnership)?.OwnerName ?? + "another DI-aware hosting registration"; + throw new InvalidOperationException( + $"The OPC UA DI namespace and address space are already owned by " + + $"'{ownerName}'. AddRobotics cannot register a second DI-owning manager."); + } + } + + services.AddSingleton(new DiAddressSpaceOwnership(nameof(AddRobotics))); + } + + private interface IRoboticsConfigurationRegistration + { + Type TargetManagerType { get; } + + ValueTask ConfigureAsync(IRoboticsBuildContext context); + } + + private sealed class DelegateRoboticsConfigurationRegistration + : IRoboticsConfigurationRegistration + { + private readonly Func< + IRoboticsBuildContext, + CancellationToken, + ValueTask> m_configure; + + public DelegateRoboticsConfigurationRegistration( + Type targetManagerType, + Func configure) + { + TargetManagerType = targetManagerType; + m_configure = configure; + } + + public Type TargetManagerType { get; } + + public ValueTask ConfigureAsync(IRoboticsBuildContext context) + { + return m_configure(context, context.CancellationToken); + } + } + + private sealed class ClassRoboticsConfigurationRegistration + : IRoboticsConfigurationRegistration + where TConfigurator : class, IRoboticsConfigurator + { + public ClassRoboticsConfigurationRegistration(Type targetManagerType) + { + TargetManagerType = targetManagerType; + } + + public Type TargetManagerType { get; } + + public ValueTask ConfigureAsync(IRoboticsBuildContext context) + { + return context.GetRequiredService().ConfigureAsync( + context, + context.CancellationToken); + } + } + + private sealed class RoboticsPipelineMarker + where TNodeManager : DiNodeManager; + + } +} diff --git a/src/Opc.Ua.Robotics.Server/IRoboticsBuildContext.cs b/src/Opc.Ua.Robotics.Server/IRoboticsBuildContext.cs new file mode 100644 index 0000000000..ed7c7d6eeb --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/IRoboticsBuildContext.cs @@ -0,0 +1,84 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using Opc.Ua.Di.Server; +using Opc.Ua.Server.Fluent; + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Build context shared by Robotics hosting configurators. + /// + public interface IRoboticsBuildContext + { + /// + /// Gets the active DI node manager. + /// + DiNodeManager Manager { get; } + + /// + /// Gets the active system context. + /// + ISystemContext Context { get; } + + /// + /// Gets the single fluent node-manager builder owned by this context. + /// + INodeManagerBuilder Nodes { get; } + + /// + /// Gets the application-owned instance namespace index. + /// + ushort InstanceNamespaceIndex { get; } + + /// + /// Gets the DI DeviceSet node. + /// + NodeState DeviceSet { get; } + + /// + /// Gets the hosting cancellation token. + /// + CancellationToken CancellationToken { get; } + + /// + /// Resolves a required application service. + /// + /// + /// The required service contract. + /// + T GetRequiredService() where T : notnull; + + /// + /// Seals the builder and starts configured simulations. + /// + void Seal(); + } +} diff --git a/src/Opc.Ua.Robotics.Server/IRoboticsConfigurator.cs b/src/Opc.Ua.Robotics.Server/IRoboticsConfigurator.cs new file mode 100644 index 0000000000..62a5f71f14 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/IRoboticsConfigurator.cs @@ -0,0 +1,47 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Configures a Robotics address space after predefined models are available. + /// + public interface IRoboticsConfigurator + { + /// + /// Configures the active Robotics build context. + /// + ValueTask ConfigureAsync( + IRoboticsBuildContext context, + CancellationToken cancellationToken); + } +} diff --git a/src/Opc.Ua.Robotics.Server/IRoboticsModelProvider.cs b/src/Opc.Ua.Robotics.Server/IRoboticsModelProvider.cs new file mode 100644 index 0000000000..f0b0d58aed --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/IRoboticsModelProvider.cs @@ -0,0 +1,55 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Contributes compiled predefined model nodes to a Robotics node manager. + /// + public interface IRoboticsModelProvider + { + /// + /// Gets the deterministic provider execution order. Providers run in + /// ascending order; the built-in core-model provider runs first. + /// + int Order { get; } + + /// + /// Gets the model namespace URIs contributed by this provider. Providers + /// that replace the built-in core-model provider must advertise the IA + /// and Robotics namespace URIs. + /// + ArrayOf NamespaceUris { get; } + + /// + /// Adds compiled predefined nodes to . + /// + void AddPredefinedNodes(NodeStateCollection nodes, ISystemContext context); + } +} diff --git a/src/Opc.Ua.Robotics.Server/IRoboticsNodeIdFactory.cs b/src/Opc.Ua.Robotics.Server/IRoboticsNodeIdFactory.cs new file mode 100644 index 0000000000..1c42f4f437 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/IRoboticsNodeIdFactory.cs @@ -0,0 +1,43 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Marks a NodeId factory that is safe for Robotics topology construction. + /// + /// + /// Implementations of New must be thread-safe, reserve unique NodeIds + /// that are not reused while created nodes remain unregistered, and allocate + /// Robotics application instances in the configured instance namespace. + /// + public interface IRoboticsNodeIdFactory : INodeIdFactory + { + } +} diff --git a/src/Opc.Ua.Robotics.Server/NugetREADME.md b/src/Opc.Ua.Robotics.Server/NugetREADME.md new file mode 100644 index 0000000000..ca73c17db2 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/NugetREADME.md @@ -0,0 +1,103 @@ +# Opc.Ua.Robotics.Server + +Server hosting for the **OPC 40010 Robotics** companion model. + +The package uses source-generated model loaders and fluent accessors. It provides: + +- `AddRoboticsTypeSystem` โ€” loads the OPC UA DI base model plus the IA and + Robotics companion models (all source-generated) into a node manager's + predefined-node collection, in dependency order. +- `RoboticsNodeManager` and `RoboticsNodeManagerFactory` โ€” a stock DI-based + server manager with an application-owned instance namespace. +- `IRoboticsModelProvider` โ€” deterministic composition of additional compiled + predefined models. Providers run in ascending `Order`; the built-in DI/IA/ + Robotics provider uses `int.MinValue` so application providers run afterward + by default. A replacement core provider must advertise both the IA and + Robotics namespaces. +- `AddRobotics`, `AddRoboticsModel`, `ConfigureRobotics`, and + `ConfigureRoboticsFor` โ€” Generic Host registration and ordered startup + configuration APIs. +- `IRoboticsBuildContext` and `IRoboticsConfigurator` โ€” one shared fluent + builder per manager startup, with narrow service resolution and simulation + startup on sealing. +- `AddMotionDeviceSystemAsync` โ€” a progressive, validated topology builder for + controllers, controller software, motion devices, axes, power trains, drives, + safety state, and task controls. Source-generated factories assign + per-instance NodeIds before the builder validates and recursively registers + the completed generated state tree. + +```csharp +builder.ConfigureRobotics(async context => +{ + await context.AddMotionDeviceSystemAsync("RobotCell", system => + { + IMotionDeviceBuilder robot = system.AddMotionDevice("Robot", motion => + motion.WithMotionDeviceCategory( + MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT)); + IDriveBuilder drive = robot.AddDrive( + "Drive", + item => item.WithProductCode("DRIVE-1")); + IPowerTrainBuilder train = robot.AddPowerTrain("PowerTrain"); + IAxisBuilder axis = robot.AddAxis( + "Axis1", + item => item.WithMotionProfile(AxisMotionProfileEnumeration.ROTARY) + .WithActualPosition(0)); + train.AddMotor("Motor", motor => motor.IsDrivenBy(drive)); + axis.Requires(train); + train.Moves(axis); + + ISafetyStateBuilder safety = system.AddSafetyState("Safety"); + IControllerBuilder controller = system.AddController("Controller"); + controller.AddSoftware("ControlSoftware", software => + software.WithIdentification(data => + { + data.Manufacturer = new LocalizedText("Vendor"); + data.Model = new LocalizedText("Robot Runtime"); + data.SoftwareRevision = "1.0"; + })); + ITaskControlBuilder taskControl = controller.AddTaskControl( + "TaskControl", + task => task.WithComponentName("Main task")); + controller.Controls(robot).UsesSafetyState(safety); + taskControl.Controls(robot); + }); +}); +``` + +Each controller must contain at least one `SoftwareType` instance and one +`TaskControlType` instance because both containers define mandatory placeholder +children. The `Controls` and `IsDrivenBy` relationships are optional. + +`ITaskControlBuilder.Controls` and `IMotionDeviceBuilder.UsesTaskControl` add +the standard `Controls` relation. When a task control also has a +`TaskControlOperationType`, the controlled motion device receives the matching +`TaskControlReference`. + +`IGearBuilder.WithPitch` accepts millimetres of linear travel per output-side +revolution. Pitch is a `BaseDataVariableType`; the builder does not add +EngineeringUnits. + +Instantiate Robotics-typed objects with the generated +`ISystemContext.CreateInstanceOf` factories (for example +`CreateInstanceOfMotionDeviceSystemType`, `CreateInstanceOfMotionDeviceType`, +`CreateInstanceOfAxisType`, `CreateInstanceOfControllerType`) so each instance carries +the full companion-type structure rather than only a type-definition reference. + +The default instance namespace is application-specific and can be changed with +`RoboticsServerOptions.InstanceNamespaceUri`. It must be distinct from the OPC +UA, DI, IA, Robotics, and all configured model-provider namespaces. + +After a custom `DiNodeManager` has loaded the DI, IA, and Robotics models, call +`manager.CreateRoboticsBuildContext(options)` for direct configuration outside +the hosting pipeline. The helper validates the DeviceSet, Robotics model, and +configured instance namespace before returning the context. Its +`Context.NodeIdFactory` must implement `IRoboticsNodeIdFactory`; the allocator +must be thread-safe, reserve unique NodeIds for unregistered nodes, and allocate +Robotics instances in the configured instance namespace. + +`AddRobotics()` owns the DI namespace and cannot be combined with +`AddOpcUaDi()`. Other companion-manager hosting extensions that load DI should +use the shared `DiAddressSpaceOwnership` marker to enforce the same rule. + +See the [Robotics developer guide](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/Robotics.md) +and the [Dependency Injection guide](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/DependencyInjection.md). diff --git a/src/Opc.Ua.Robotics.Server/Opc.Ua.Robotics.Server.csproj b/src/Opc.Ua.Robotics.Server/Opc.Ua.Robotics.Server.csproj new file mode 100644 index 0000000000..3a7878179e --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Opc.Ua.Robotics.Server.csproj @@ -0,0 +1,49 @@ + + + $(AssemblyPrefix).Robotics.Server + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.Robotics.Server + Opc.Ua.Robotics.Server + $(NoWarn);CS1591 + enable + Server hosting for the source-generated OPC 40010 Robotics model, including compiled model providers, a stock DI-based node manager and factory, and fluent application configuration APIs. + true + NugetREADME.md + MIT + true + true + + + $(PackageId).Debug + + + + + + + + + + + + + Analyzer + false + + + + + Model\Opc.Ua.Robotics.NodeSet2.xml + Opc.Ua.Robotics + + + + + v105 + true + true + + + + + diff --git a/src/Opc.Ua.Robotics.Server/Properties/AssemblyInfo.cs b/src/Opc.Ua.Robotics.Server/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.Robotics.Server/RoboticsBuildContext.cs b/src/Opc.Ua.Robotics.Server/RoboticsBuildContext.cs new file mode 100644 index 0000000000..fa3a259503 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/RoboticsBuildContext.cs @@ -0,0 +1,265 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using Opc.Ua.Di.Server; +using Opc.Ua.Di.Server.Hosting; +using Opc.Ua.Server.Fluent; + +namespace Opc.Ua.Robotics.Server +{ + internal interface IRoboticsBuildCoordinator + { + bool IsSealed { get; } + + IDisposable AcquireBuildLease(); + + bool ReleasesNodeIdReservations { get; } + + void ReleaseNodeIdReservation(NodeState node); + + IDisposable ReserveRootBrowseName( + NodeState parent, + QualifiedName browseName); + } + + internal sealed class RoboticsBuildContext : + IRoboticsBuildContext, + IRoboticsBuildCoordinator + { + public RoboticsBuildContext( + DiNodeManager manager, + RoboticsServerOptions options, + CancellationToken cancellationToken, + IDiPostSetupContext? postSetupContext = null) + { + Manager = manager ?? throw new ArgumentNullException(nameof(manager)); + options = RoboticsModelProviderUtilities.ValidateOptions(options); + CancellationToken = cancellationToken; + m_postSetupContext = postSetupContext; + + Context = manager.SystemContext; + if (Context.NodeIdFactory is not IRoboticsNodeIdFactory) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The custom DI node manager '{0}' cannot build Robotics instances because " + + "Context.NodeIdFactory does not implement {1}. Implement the marker on the " + + "manager with a thread-safe allocator for the configured instance namespace, " + + "or assign such a factory before ConfigureRoboticsFor/CreateRoboticsBuildContext.", + manager.GetType().FullName ?? manager.GetType().Name, + nameof(IRoboticsNodeIdFactory)); + } + m_releasesNodeIdReservations = + manager is RoboticsNodeManager && + ReferenceEquals(Context.NodeIdFactory, manager); + + int iaNamespaceIndex = Context.NamespaceUris.GetIndex( + Opc.Ua.IA.Namespaces.IA); + if (iaNamespaceIndex < 0) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The IA namespace '{0}' is not registered.", + Opc.Ua.IA.Namespaces.IA); + } + + int roboticsNamespaceIndex = Context.NamespaceUris.GetIndex( + Opc.Ua.Robotics.Namespaces.Robotics); + if (roboticsNamespaceIndex < 0) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The Robotics namespace '{0}' is not registered.", + Opc.Ua.Robotics.Namespaces.Robotics); + } + + int instanceNamespaceIndex = Context.NamespaceUris.GetIndex( + options.InstanceNamespaceUri); + if (instanceNamespaceIndex < 0) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The Robotics instance namespace '{0}' is not registered.", + options.InstanceNamespaceUri); + } + InstanceNamespaceIndex = (ushort)instanceNamespaceIndex; + m_managerCoordinator = RoboticsBuildCoordinator.Get(manager); + + var deviceSetId = NodeId.Create( + Opc.Ua.Di.Objects.DeviceSet, + DiNodeManager.DiNamespaceUri, + Context.NamespaceUris); + DeviceSet = manager.FindPredefinedNode(deviceSetId) ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The DI DeviceSet node is not available in the Robotics address space."); + + var motionDeviceSystemTypeId = NodeId.Create( + Opc.Ua.Robotics.ObjectTypes.MotionDeviceSystemType, + Opc.Ua.Robotics.Namespaces.Robotics, + Context.NamespaceUris); + _ = manager.FindPredefinedNode(motionDeviceSystemTypeId) ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The Robotics MotionDeviceSystemType is not available in the address space."); + + m_nodes = manager.CreateFluentBuilder(InstanceNamespaceIndex); + } + + public DiNodeManager Manager { get; } + + public ISystemContext Context { get; } + + public INodeManagerBuilder Nodes => m_nodes; + + public ushort InstanceNamespaceIndex { get; } + + public NodeState DeviceSet { get; } + + public CancellationToken CancellationToken { get; } + + internal bool IsSealed + { + get + { + lock (m_stateLock) + { + return m_sealed; + } + } + } + + public T GetRequiredService() where T : notnull + { + if (m_postSetupContext == null) + { + throw new InvalidOperationException( + "Application services are unavailable for a directly created Robotics build context."); + } + return m_postSetupContext.GetRequiredService(); + } + + public void Seal() + { + lock (m_stateLock) + { + if (m_sealed) + { + return; + } + if (m_activeBuildLeaseCount != 0) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "The Robotics build context cannot be sealed while a build is active."); + } + + m_sealed = true; + m_nodes.Seal(); + } + } + + IDisposable IRoboticsBuildCoordinator.AcquireBuildLease() + { + lock (m_stateLock) + { + if (m_sealed) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidState, + "The Robotics build context is sealed."); + } + m_activeBuildLeaseCount++; + } + return new BuildLease(this); + } + + void IRoboticsBuildCoordinator.ReleaseNodeIdReservation(NodeState node) + { + if (m_releasesNodeIdReservations) + { + m_managerCoordinator.ReleaseNodeId(node); + } + } + + IDisposable IRoboticsBuildCoordinator.ReserveRootBrowseName( + NodeState parent, + QualifiedName browseName) + { + return m_managerCoordinator.ReserveRootBrowseName( + Context, + parent, + browseName); + } + + bool IRoboticsBuildCoordinator.IsSealed => IsSealed; + + bool IRoboticsBuildCoordinator.ReleasesNodeIdReservations => + m_releasesNodeIdReservations; + + private void ReleaseBuildLease() + { + lock (m_stateLock) + { + if (m_activeBuildLeaseCount == 0) + { + throw new InvalidOperationException( + "The Robotics build context has no active build lease to release."); + } + m_activeBuildLeaseCount--; + } + } + + private readonly NodeManagerBuilder m_nodes; + private readonly IDiPostSetupContext? m_postSetupContext; + private readonly RoboticsBuildCoordinator m_managerCoordinator; + private readonly Lock m_stateLock = new(); + private readonly bool m_releasesNodeIdReservations; + private int m_activeBuildLeaseCount; + private bool m_sealed; + + private sealed class BuildLease : IDisposable + { + public BuildLease(RoboticsBuildContext context) + { + m_context = context; + } + + public void Dispose() + { + RoboticsBuildContext? context = Interlocked.Exchange(ref m_context, null); + context?.ReleaseBuildLease(); + } + + private RoboticsBuildContext? m_context; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/RoboticsBuildCoordinator.cs b/src/Opc.Ua.Robotics.Server/RoboticsBuildCoordinator.cs new file mode 100644 index 0000000000..7d7030f801 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/RoboticsBuildCoordinator.cs @@ -0,0 +1,305 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Di.Server; + +namespace Opc.Ua.Robotics.Server +{ + internal sealed class RoboticsBuildCoordinator + { + public static RoboticsBuildCoordinator Get(DiNodeManager manager) + { + if (manager == null) + { + throw new ArgumentNullException(nameof(manager)); + } + + return s_managerCoordinators.GetValue( + manager, + static _ => new RoboticsBuildCoordinator()); + } + + public NodeId ReserveNodeId( + DiNodeManager manager, + ushort namespaceIndex, + NodeState node) + { + if (manager == null) + { + throw new ArgumentNullException(nameof(manager)); + } + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + lock (m_lock) + { + NamespaceNodeIdReservations reservations = + GetNodeIdReservations(namespaceIndex); + if (node.NodeId.NamespaceIndex == namespaceIndex && + reservations.Owners.TryGetValue( + node.NodeId, + out WeakReference? owner) && + owner.TryGetTarget(out NodeState? reservedNode) && + ReferenceEquals(reservedNode, node)) + { + return node.NodeId; + } + + while (true) + { + uint lastUsedNodeId = reservations.LastUsedNodeId; + uint identifier = Utils.IncrementIdentifier(ref lastUsedNodeId); + reservations.LastUsedNodeId = lastUsedNodeId; + + var candidate = new NodeId(identifier, namespaceIndex); + if (reservations.NodeIds.Contains(candidate) || + manager.FindPredefinedNode(candidate) != null) + { + continue; + } + + reservations.NodeIds.Add(candidate); + reservations.Owners.Add( + candidate, + new WeakReference(node)); + return candidate; + } + } + } + + public void ReleaseNodeId(NodeId nodeId, NodeState node) + { + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + lock (m_lock) + { + if (m_nodeIdReservations.TryGetValue( + nodeId.NamespaceIndex, + out NamespaceNodeIdReservations? reservations) && + reservations.Owners.TryGetValue( + nodeId, + out WeakReference? owner) && + owner.TryGetTarget(out NodeState? reservedNode) && + ReferenceEquals(reservedNode, node)) + { + reservations.NodeIds.Remove(nodeId); + reservations.Owners.Remove(nodeId); + } + } + } + + public void ReleaseNodeId(NodeState node) + { + if (node == null) + { + throw new ArgumentNullException(nameof(node)); + } + + lock (m_lock) + { + foreach (NamespaceNodeIdReservations reservations + in m_nodeIdReservations.Values) + { + var nodeIds = new List(); + foreach (KeyValuePair< + NodeId, + WeakReference> reservation in reservations.Owners) + { + if (reservation.Value.TryGetTarget(out NodeState? reservedNode) && + ReferenceEquals(reservedNode, node)) + { + nodeIds.Add(reservation.Key); + } + } + + for (int ii = 0; ii < nodeIds.Count; ii++) + { + NodeId nodeId = nodeIds[ii]; + reservations.NodeIds.Remove(nodeId); + reservations.Owners.Remove(nodeId); + } + } + } + } + + public int GetReservedNodeIdCount(ushort namespaceIndex) + { + lock (m_lock) + { + return m_nodeIdReservations.TryGetValue( + namespaceIndex, + out NamespaceNodeIdReservations? reservations) + ? reservations.NodeIds.Count + : 0; + } + } + + public IDisposable ReserveRootBrowseName( + ISystemContext context, + NodeState parent, + QualifiedName browseName) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (parent == null) + { + throw new ArgumentNullException(nameof(parent)); + } + if (parent.NodeId.IsNull) + { + throw ServiceResultException.Create( + StatusCodes.BadInvalidArgument, + "A parent NodeId is required to reserve root BrowseName '{0}'.", + browseName); + } + + NodeId parentNodeId = parent.NodeId; + lock (m_lock) + { + var children = new List(); + parent.GetChildren(context, children); + for (int ii = 0; ii < children.Count; ii++) + { + if (children[ii].BrowseName == browseName) + { + throw CreateDuplicateBrowseNameException(parent, browseName); + } + } + + if (!m_rootBrowseNameReservations.TryGetValue( + parentNodeId, + out HashSet? reservations)) + { + reservations = []; + m_rootBrowseNameReservations.Add(parentNodeId, reservations); + } + if (!reservations.Add(browseName)) + { + throw CreateDuplicateBrowseNameException(parent, browseName); + } + } + + return new RootBrowseNameReservation(this, parentNodeId, browseName); + } + + private static ServiceResultException CreateDuplicateBrowseNameException( + NodeState parent, + QualifiedName browseName) + { + return ServiceResultException.Create( + StatusCodes.BadBrowseNameDuplicated, + "Parent '{0}' already contains or is creating a child named '{1}'.", + parent.BrowseName, + browseName); + } + + private NamespaceNodeIdReservations GetNodeIdReservations( + ushort namespaceIndex) + { + if (!m_nodeIdReservations.TryGetValue( + namespaceIndex, + out NamespaceNodeIdReservations? reservations)) + { + reservations = new NamespaceNodeIdReservations(); + m_nodeIdReservations.Add(namespaceIndex, reservations); + } + return reservations; + } + + private void ReleaseRootBrowseName( + NodeId parentNodeId, + QualifiedName browseName) + { + lock (m_lock) + { + if (m_rootBrowseNameReservations.TryGetValue( + parentNodeId, + out HashSet? reservations) && + reservations.Remove(browseName) && + reservations.Count == 0) + { + m_rootBrowseNameReservations.Remove(parentNodeId); + } + } + } + + private static readonly ConditionalWeakTable< + DiNodeManager, + RoboticsBuildCoordinator> s_managerCoordinators = new(); + private readonly Dictionary + m_nodeIdReservations = []; + private readonly Dictionary> + m_rootBrowseNameReservations = []; + private readonly Lock m_lock = new(); + + private sealed class NamespaceNodeIdReservations + { + public HashSet NodeIds { get; } = []; + + public Dictionary> Owners { get; } = []; + + public uint LastUsedNodeId { get; set; } + } + + private sealed class RootBrowseNameReservation : IDisposable + { + public RootBrowseNameReservation( + RoboticsBuildCoordinator coordinator, + NodeId parentNodeId, + QualifiedName browseName) + { + m_coordinator = coordinator; + m_parentNodeId = parentNodeId; + m_browseName = browseName; + } + + public void Dispose() + { + RoboticsBuildCoordinator? coordinator = + Interlocked.Exchange(ref m_coordinator, null); + coordinator?.ReleaseRootBrowseName(m_parentNodeId, m_browseName); + } + + private RoboticsBuildCoordinator? m_coordinator; + private readonly NodeId m_parentNodeId; + private readonly QualifiedName m_browseName; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/RoboticsModelProvider.cs b/src/Opc.Ua.Robotics.Server/RoboticsModelProvider.cs new file mode 100644 index 0000000000..67bcfddf50 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/RoboticsModelProvider.cs @@ -0,0 +1,283 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Built-in compiled model provider for DI, IA, and Robotics. + /// + public sealed class RoboticsModelProvider : IRoboticsModelProvider + { + /// + public int Order => int.MinValue; + + /// + public ArrayOf NamespaceUris => new string[] + { + Opc.Ua.IA.Namespaces.IA, + Opc.Ua.Robotics.Namespaces.Robotics + }; + + /// + public void AddPredefinedNodes(NodeStateCollection nodes, ISystemContext context) + { + if (nodes == null) + { + throw new ArgumentNullException(nameof(nodes)); + } + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + nodes.AddRoboticsTypeSystem(context); + } + } + + internal static class RoboticsModelProviderUtilities + { + public static ArrayOf Normalize( + ArrayOf providers) + { + if (providers.IsNull) + { + throw new ArgumentNullException(nameof(providers)); + } + if (providers.IsEmpty) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "At least one Robotics model provider must be configured."); + } + + var entries = new List(providers.Count); + for (int ii = 0; ii < providers.Count; ii++) + { + IRoboticsModelProvider provider = providers[ii] ?? + throw new ArgumentException( + "Robotics model providers must not contain null entries.", + nameof(providers)); + entries.Add(new ProviderEntry(provider, ii)); + } + + entries.Sort(static (left, right) => + { + bool leftIsBuiltIn = left.Provider is RoboticsModelProvider; + bool rightIsBuiltIn = right.Provider is RoboticsModelProvider; + if (leftIsBuiltIn != rightIsBuiltIn) + { + return leftIsBuiltIn ? -1 : 1; + } + + int result = left.Provider.Order.CompareTo(right.Provider.Order); + if (result != 0) + { + return result; + } + + result = string.Compare( + GetStableTypeName(left.Provider), + GetStableTypeName(right.Provider), + StringComparison.Ordinal); + return result != 0 ? result : left.OriginalIndex.CompareTo(right.OriginalIndex); + }); + + var result = new IRoboticsModelProvider[entries.Count]; + for (int ii = 0; ii < entries.Count; ii++) + { + result[ii] = entries[ii].Provider; + } + + ValidateRequiredNamespaces(result); + return result; + } + + public static string[] GetManagerNamespaceUris( + ArrayOf providers, + RoboticsServerOptions options) + { + options = ValidateOptions(options); + ArrayOf normalized = Normalize(providers); + ValidateInstanceNamespace(options, normalized); + var namespaceUris = new List(); + AddProviderNamespaces(namespaceUris, normalized); + AddNamespace(namespaceUris, options.InstanceNamespaceUri); + namespaceUris.RemoveAll( + static namespaceUri => namespaceUri == Opc.Ua.Di.Server.DiNodeManager.DiNamespaceUri); + return namespaceUris.ToArray(); + } + + public static ArrayOf GetFactoryNamespaceUris( + ArrayOf providers, + RoboticsServerOptions options) + { + options = ValidateOptions(options); + ArrayOf normalized = Normalize(providers); + ValidateInstanceNamespace(options, normalized); + var namespaceUris = new List(); + AddProviderNamespaces(namespaceUris, normalized); + AddNamespace(namespaceUris, options.InstanceNamespaceUri); + AddNamespace(namespaceUris, Opc.Ua.Di.Server.DiNodeManager.DiNamespaceUri); + return namespaceUris; + } + + public static RoboticsServerOptions ValidateOptions(RoboticsServerOptions options) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + options.Validate(); + return options; + } + + public static RoboticsServerOptions ValidateOptions( + RoboticsServerOptions options, + ArrayOf providers) + { + options = ValidateOptions(options); + ValidateInstanceNamespace(options, providers); + return options; + } + + private static void AddProviderNamespaces( + List namespaceUris, + ArrayOf providers) + { + for (int providerIndex = 0; providerIndex < providers.Count; providerIndex++) + { + ArrayOf providerNamespaces = providers[providerIndex].NamespaceUris; + for (int namespaceIndex = 0; + namespaceIndex < providerNamespaces.Count; + namespaceIndex++) + { + AddNamespace(namespaceUris, providerNamespaces[namespaceIndex]); + } + } + } + + private static void ValidateRequiredNamespaces( + ArrayOf providers) + { + bool hasIa = false; + bool hasRobotics = false; + for (int providerIndex = 0; providerIndex < providers.Count; providerIndex++) + { + ArrayOf providerNamespaces = providers[providerIndex].NamespaceUris; + if (providerNamespaces.IsNull) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Robotics model provider '{0}' returned a null namespace URI list.", + GetStableTypeName(providers[providerIndex])); + } + + for (int namespaceIndex = 0; + namespaceIndex < providerNamespaces.Count; + namespaceIndex++) + { + string namespaceUri = providerNamespaces[namespaceIndex]; + if (string.IsNullOrWhiteSpace(namespaceUri)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Robotics model provider '{0}' advertised an empty namespace URI.", + GetStableTypeName(providers[providerIndex])); + } + + hasIa |= namespaceUri == Opc.Ua.IA.Namespaces.IA; + hasRobotics |= namespaceUri == Opc.Ua.Robotics.Namespaces.Robotics; + } + } + + if (!hasIa || !hasRobotics) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "Robotics model providers must collectively advertise the IA namespace '{0}' " + + "and Robotics namespace '{1}'.", + Opc.Ua.IA.Namespaces.IA, + Opc.Ua.Robotics.Namespaces.Robotics); + } + } + + private static void ValidateInstanceNamespace( + RoboticsServerOptions options, + ArrayOf providers) + { + for (int providerIndex = 0; providerIndex < providers.Count; providerIndex++) + { + ArrayOf providerNamespaces = providers[providerIndex].NamespaceUris; + for (int namespaceIndex = 0; + namespaceIndex < providerNamespaces.Count; + namespaceIndex++) + { + if (options.InstanceNamespaceUri == providerNamespaces[namespaceIndex]) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "RoboticsServerOptions.InstanceNamespaceUri '{0}' is provided by " + + "Robotics model provider '{1}'. Configure a distinct " + + "application-owned namespace for Robotics instances.", + options.InstanceNamespaceUri, + GetStableTypeName(providers[providerIndex])); + } + } + } + } + + private static void AddNamespace(List namespaceUris, string namespaceUri) + { + if (string.IsNullOrWhiteSpace(namespaceUri)) + { + throw new ArgumentException( + "Robotics model providers must advertise non-empty namespace URIs.", + nameof(namespaceUri)); + } + + if (!namespaceUris.Contains(namespaceUri)) + { + namespaceUris.Add(namespaceUri); + } + } + + private static string GetStableTypeName(IRoboticsModelProvider provider) + { + Type type = provider.GetType(); + return type.FullName ?? type.Name; + } + + private readonly record struct ProviderEntry( + IRoboticsModelProvider Provider, + int OriginalIndex); + } +} diff --git a/src/Opc.Ua.Robotics.Server/RoboticsNodeManager.cs b/src/Opc.Ua.Robotics.Server/RoboticsNodeManager.cs new file mode 100644 index 0000000000..6722f8cead --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/RoboticsNodeManager.cs @@ -0,0 +1,180 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Di.Server; +using Opc.Ua.Di.Server.Hosting; +using Opc.Ua.Server; + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Stock DI-based node manager for compiled Robotics models and + /// application-owned instances. + /// + public sealed class RoboticsNodeManager : DiNodeManager, IRoboticsNodeIdFactory + { + private readonly RoboticsServerOptions m_options; + private readonly ArrayOf m_providers; + private readonly RoboticsBuildCoordinator m_buildCoordinator; + private bool m_addressSpaceReady; + + /// + /// Creates a stock manager with default options and the built-in provider. + /// + public RoboticsNodeManager( + IServerInternal server, + ApplicationConfiguration configuration) + : this( + server, + configuration, + new IRoboticsModelProvider[] { new RoboticsModelProvider() }, + new RoboticsServerOptions()) + { + } + + /// + /// Creates a stock manager with explicitly supplied providers and options. + /// + public RoboticsNodeManager( + IServerInternal server, + ApplicationConfiguration configuration, + ArrayOf providers, + RoboticsServerOptions options, + IDiPostSetupRunner? postSetupRunner = null) + : base( + server, + configuration, + postSetupRunner, + RoboticsModelProviderUtilities.GetManagerNamespaceUris(providers, options)) + { + m_providers = RoboticsModelProviderUtilities.Normalize(providers); + m_options = RoboticsModelProviderUtilities.ValidateOptions(options, m_providers); + m_buildCoordinator = RoboticsBuildCoordinator.Get(this); + } + + /// + /// Gets the application-owned instance namespace index. + /// + public ushort InstanceNamespaceIndex + { + get + { + int namespaceIndex = Server.NamespaceUris.GetIndex( + m_options.InstanceNamespaceUri); + if (namespaceIndex < 0) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The Robotics instance namespace '{0}' is not registered.", + m_options.InstanceNamespaceUri); + } + return (ushort)namespaceIndex; + } + } + + internal int ReservedNodeIdCount => + m_buildCoordinator.GetReservedNodeIdCount(InstanceNamespaceIndex); + + /// + /// Creates a build context for direct, non-hosted configuration. + /// + /// + /// The address space or required models are not available. + /// + public IRoboticsBuildContext CreateRoboticsBuildContext( + CancellationToken cancellationToken = default) + { + if (!m_addressSpaceReady) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The Robotics address space is not available yet."); + } + return new RoboticsBuildContext(this, m_options, cancellationToken); + } + + /// + public override NodeId New(ISystemContext context, NodeState node) + { + if (node.NodeId.IsNull) + { + return m_buildCoordinator.ReserveNodeId( + this, + InstanceNamespaceIndex, + node); + } + return node.NodeId; + } + + /// + protected override async ValueTask AddPredefinedNodeAsync( + ISystemContext context, + NodeState node, + CancellationToken cancellationToken = default) + { + try + { + await base.AddPredefinedNodeAsync(context, node, cancellationToken) + .ConfigureAwait(false); + } + finally + { + if (!node.NodeId.IsNull && + ReferenceEquals(FindPredefinedNode(node.NodeId), node)) + { + m_buildCoordinator.ReleaseNodeId(node.NodeId, node); + } + } + } + + /// + protected override ValueTask LoadPredefinedNodesAsync( + ISystemContext context, + CancellationToken cancellationToken = default) + { + var nodes = new NodeStateCollection(); + for (int ii = 0; ii < m_providers.Count; ii++) + { + m_providers[ii].AddPredefinedNodes(nodes, context); + } + return new ValueTask(nodes); + } + + /// + protected override ValueTask OnAddressSpaceReadyAsync( + CancellationToken cancellationToken) + { + m_addressSpaceReady = true; + return default; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/RoboticsNodeManagerFactory.cs b/src/Opc.Ua.Robotics.Server/RoboticsNodeManagerFactory.cs new file mode 100644 index 0000000000..4a0ea340d7 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/RoboticsNodeManagerFactory.cs @@ -0,0 +1,93 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Di.Server.Hosting; +using Opc.Ua.Server; + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Creates stock instances. + /// + public sealed class RoboticsNodeManagerFactory : IAsyncNodeManagerFactory + { + private readonly RoboticsServerOptions m_options; + private readonly ArrayOf m_providers; + private readonly IDiPostSetupRunner? m_postSetupRunner; + + /// + /// Creates a factory with default options and the built-in provider. + /// + public RoboticsNodeManagerFactory() + : this( + new IRoboticsModelProvider[] { new RoboticsModelProvider() }, + new RoboticsServerOptions()) + { + } + + /// + /// Creates a factory with explicitly supplied providers and options. + /// + public RoboticsNodeManagerFactory( + ArrayOf providers, + RoboticsServerOptions options, + IDiPostSetupRunner? postSetupRunner = null) + { + m_providers = RoboticsModelProviderUtilities.Normalize(providers); + m_options = RoboticsModelProviderUtilities.ValidateOptions(options, m_providers); + m_postSetupRunner = postSetupRunner; + } + + /// + public ArrayOf NamespacesUris => + RoboticsModelProviderUtilities.GetFactoryNamespaceUris(m_providers, m_options); + + /// + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the node manager is transferred to the server.")] + public ValueTask CreateAsync( + IServerInternal server, + ApplicationConfiguration configuration, + CancellationToken cancellationToken = default) + { + IAsyncNodeManager nodeManager = new RoboticsNodeManager( + server, + configuration, + m_providers, + m_options, + m_postSetupRunner); + return new ValueTask(nodeManager); + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/RoboticsServer.cs b/src/Opc.Ua.Robotics.Server/RoboticsServer.cs new file mode 100644 index 0000000000..7ecc975cb4 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/RoboticsServer.cs @@ -0,0 +1,70 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Di; +using Opc.Ua.IA; + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Reusable server-side helper for the OPC 40010 Robotics companion model: loads the + /// Robotics type system into a node manager. Instantiate Robotics-typed objects with + /// the generated ISystemContext.CreateInstanceOf<Type> factories (e.g. + /// CreateInstanceOfMotionDeviceSystemType) so instances carry the full + /// companion-type structure rather than only a type-definition reference. + /// + public static class RoboticsServer + { + /// + /// Loads the full Robotics type system into : the + /// OPC UA DI base model, then the IA and Robotics companion models (all + /// source-generated), in dependency order. Call this while building the + /// predefined-node collection of a node manager. Returns the number of + /// nodes added by the IA + Robotics models. + /// + public static int AddRoboticsTypeSystem( + this NodeStateCollection nodes, ISystemContext context) + { + if (nodes is null) + { + throw new ArgumentNullException(nameof(nodes)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + nodes.AddOpcUaDi(context); + int before = nodes.Count; + nodes.AddOpcUaIA(context); + nodes.AddOpcUaRobotics(context); + return nodes.Count - before; + } + } +} diff --git a/src/Opc.Ua.Robotics.Server/RoboticsServerOptions.cs b/src/Opc.Ua.Robotics.Server/RoboticsServerOptions.cs new file mode 100644 index 0000000000..e519eb3972 --- /dev/null +++ b/src/Opc.Ua.Robotics.Server/RoboticsServerOptions.cs @@ -0,0 +1,94 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Robotics.Server +{ + /// + /// Configures the stock Robotics server hosting integration. + /// + public sealed class RoboticsServerOptions + { + /// + /// Default application-owned namespace for Robotics instances. + /// + public const string DefaultInstanceNamespaceUri = + "urn:opcua-netstandard:robotics:instances"; + + /// + /// Gets or sets the application-owned namespace URI used for + /// dynamically created Robotics instances. + /// + /// + /// This namespace is application-specific and is not an OPC Foundation + /// companion-specification or configured model-provider namespace. + /// + public string InstanceNamespaceUri { get; set; } = DefaultInstanceNamespaceUri; + + /// + /// Validates the configured namespace URI. + /// + /// + /// is empty or is not an absolute URI. + /// + /// + /// is a standard model namespace. + /// + public void Validate() + { + if (string.IsNullOrWhiteSpace(InstanceNamespaceUri)) + { + throw new ArgumentException( + "RoboticsServerOptions.InstanceNamespaceUri must not be empty.", + nameof(InstanceNamespaceUri)); + } + + if (!Uri.TryCreate(InstanceNamespaceUri, UriKind.Absolute, out Uri? uri) || + string.IsNullOrEmpty(uri.Scheme)) + { + throw new ArgumentException( + "RoboticsServerOptions.InstanceNamespaceUri must be an absolute URI or URN.", + nameof(InstanceNamespaceUri)); + } + + if (InstanceNamespaceUri == global::Opc.Ua.Namespaces.OpcUa || + InstanceNamespaceUri == global::Opc.Ua.Di.Server.DiNodeManager.DiNamespaceUri || + InstanceNamespaceUri == global::Opc.Ua.IA.Namespaces.IA || + InstanceNamespaceUri == global::Opc.Ua.Robotics.Namespaces.Robotics) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "RoboticsServerOptions.InstanceNamespaceUri '{0}' is a model namespace. " + + "Configure a distinct application-owned namespace for Robotics instances.", + InstanceNamespaceUri); + } + } + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/AxisEngineeringOptions.cs b/src/Opc.Ua.Robotics/Contracts/AxisEngineeringOptions.cs new file mode 100644 index 0000000000..3f7cbfc31c --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/AxisEngineeringOptions.cs @@ -0,0 +1,57 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Engineering units and limits for the standard AxisType telemetry values. + /// + public sealed record AxisEngineeringOptions + { + /// + /// The engineering unit for ActualPosition. + /// + public EUInformation? PositionUnit { get; init; } + + /// + /// The engineering unit for ActualSpeed. + /// + public EUInformation? SpeedUnit { get; init; } + + /// + /// The engineering unit for ActualAcceleration. + /// + public EUInformation? AccelerationUnit { get; init; } + + /// + /// The advertised axis ranges. + /// + public AxisLimits Limits { get; init; } = new(); + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/AxisLimits.cs b/src/Opc.Ua.Robotics/Contracts/AxisLimits.cs new file mode 100644 index 0000000000..fac6242ae8 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/AxisLimits.cs @@ -0,0 +1,52 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Engineering limits for the standard AxisType telemetry values. + /// + public sealed record AxisLimits + { + /// + /// The supported position range. + /// + public Range? Position { get; init; } + + /// + /// The supported speed range. + /// + public Range? Speed { get; init; } + + /// + /// The supported acceleration range. + /// + public Range? Acceleration { get; init; } + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/AxisSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/AxisSnapshot.cs new file mode 100644 index 0000000000..8be0480712 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/AxisSnapshot.cs @@ -0,0 +1,62 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of an AxisType instance. + /// + public sealed record AxisSnapshot + { + /// + /// The axis identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + + /// + /// The generated OPC 40010 axis motion profile. + /// + public AxisMotionProfileEnumeration MotionProfile { get; init; } + + /// + /// Engineering units and limits for axis telemetry. + /// + public AxisEngineeringOptions Engineering { get; init; } = new(); + + /// + /// Current axis telemetry values. + /// + public AxisStateSnapshot State { get; init; } = new(); + + /// + /// The contained additional-load instance, or when absent. + /// + public NodeId AdditionalLoadId { get; init; } = NodeId.Null; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/AxisStateSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/AxisStateSnapshot.cs new file mode 100644 index 0000000000..98e5857a5c --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/AxisStateSnapshot.cs @@ -0,0 +1,52 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Timestamped and status-bearing values for the standard AxisType telemetry. + /// + public sealed record AxisStateSnapshot + { + /// + /// The ActualPosition value, including status and timestamps. + /// + public DataValue ActualPosition { get; init; } = DataValue.Null; + + /// + /// The ActualSpeed value, including status and timestamps. + /// + public DataValue ActualSpeed { get; init; } = DataValue.Null; + + /// + /// The ActualAcceleration value, including status and timestamps. + /// + public DataValue ActualAcceleration { get; init; } = DataValue.Null; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/ControllerSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/ControllerSnapshot.cs new file mode 100644 index 0000000000..d19e2cc0ff --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/ControllerSnapshot.cs @@ -0,0 +1,52 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a ControllerType instance. + /// + public sealed record ControllerSnapshot + { + /// + /// The controller identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + + /// + /// Task controls hosted by this controller. + /// + public ArrayOf TaskControlIds { get; init; } = []; + + /// + /// Additional DI component instance NodeIds installed in the controller. + /// + public ArrayOf ComponentIds { get; init; } = []; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/DriveSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/DriveSnapshot.cs new file mode 100644 index 0000000000..2e62e249d6 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/DriveSnapshot.cs @@ -0,0 +1,42 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a DriveType instance. + /// + public sealed record DriveSnapshot + { + /// + /// The drive identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/GearSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/GearSnapshot.cs new file mode 100644 index 0000000000..84752ae986 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/GearSnapshot.cs @@ -0,0 +1,52 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a GearType instance. + /// + public sealed record GearSnapshot + { + /// + /// The gear identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + + /// + /// The gear ratio expressed with the standard RationalNumber type. + /// + public RationalNumber? GearRatio { get; init; } + + /// + /// The optional pitch value, including status and timestamps. + /// + public DataValue Pitch { get; init; } = DataValue.Null; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/LoadSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/LoadSnapshot.cs new file mode 100644 index 0000000000..2c7f8155db --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/LoadSnapshot.cs @@ -0,0 +1,62 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a LoadType instance. + /// + public sealed record LoadSnapshot + { + /// + /// The load instance NodeId. + /// + public NodeId NodeId { get; init; } = NodeId.Null; + + /// + /// The load mass, including status and timestamps. + /// + public DataValue Mass { get; init; } = DataValue.Null; + + /// + /// Engineering metadata for the load mass. + /// + public RoboticsEngineeringValue MassEngineering { get; init; } = new(); + + /// + /// The load center of mass, including status and timestamps. + /// + public DataValue CenterOfMass { get; init; } = DataValue.Null; + + /// + /// The load inertia, including status and timestamps. + /// + public DataValue Inertia { get; init; } = DataValue.Null; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/MotionDeviceSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/MotionDeviceSnapshot.cs new file mode 100644 index 0000000000..258fdddce2 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/MotionDeviceSnapshot.cs @@ -0,0 +1,72 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a MotionDeviceType instance. + /// + public sealed record MotionDeviceSnapshot + { + /// + /// The motion-device identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + + /// + /// The generated OPC 40010 motion-device category. + /// + public MotionDeviceCategoryEnumeration Category { get; init; } + + /// + /// The SpeedOverride value, including status and timestamps. + /// + public DataValue SpeedOverride { get; init; } = DataValue.Null; + + /// + /// Axis instance NodeIds contained by the motion device. + /// + public ArrayOf AxisIds { get; init; } = []; + + /// + /// Power-train instance NodeIds contained by the motion device. + /// + public ArrayOf PowerTrainIds { get; init; } = []; + + /// + /// Additional DI component instance NodeIds contained by the motion device. + /// + public ArrayOf AdditionalComponentIds { get; init; } = []; + + /// + /// The contained flange-load instance, or when absent. + /// + public NodeId FlangeLoadId { get; init; } = NodeId.Null; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/MotionDeviceSystemSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/MotionDeviceSystemSnapshot.cs new file mode 100644 index 0000000000..5c7471d9e6 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/MotionDeviceSystemSnapshot.cs @@ -0,0 +1,57 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a MotionDeviceSystemType instance. + /// + public sealed record MotionDeviceSystemSnapshot + { + /// + /// The system identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + + /// + /// Controller instance NodeIds contained by the system. + /// + public ArrayOf ControllerIds { get; init; } = []; + + /// + /// Motion-device instance NodeIds contained by the system. + /// + public ArrayOf MotionDeviceIds { get; init; } = []; + + /// + /// Safety-state instance NodeIds contained by the system. + /// + public ArrayOf SafetyStateIds { get; init; } = []; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/MotorSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/MotorSnapshot.cs new file mode 100644 index 0000000000..08cc87eaf7 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/MotorSnapshot.cs @@ -0,0 +1,42 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a MotorType instance. + /// + public sealed record MotorSnapshot + { + /// + /// The motor identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/PowerTrainSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/PowerTrainSnapshot.cs new file mode 100644 index 0000000000..a1574c7297 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/PowerTrainSnapshot.cs @@ -0,0 +1,52 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a PowerTrainType instance. + /// + public sealed record PowerTrainSnapshot + { + /// + /// The power-train identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + + /// + /// Motor instance NodeIds in this power train. + /// + public ArrayOf MotorIds { get; init; } = []; + + /// + /// Gear instance NodeIds in this power train. + /// + public ArrayOf GearIds { get; init; } = []; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/RoboticsComponentIdentification.cs b/src/Opc.Ua.Robotics/Contracts/RoboticsComponentIdentification.cs new file mode 100644 index 0000000000..eebbdf1157 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/RoboticsComponentIdentification.cs @@ -0,0 +1,83 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Common identification data exposed by Robotics components and the + /// underlying OPC UA DI model. + /// + public sealed record RoboticsComponentIdentification + { + /// + /// The component instance NodeId. + /// + public NodeId NodeId { get; init; } = NodeId.Null; + + /// + /// The component BrowseName. + /// + public QualifiedName BrowseName { get; init; } = QualifiedName.Null; + + /// + /// The user-facing component name. + /// + public LocalizedText ComponentName { get; init; } = LocalizedText.Null; + + /// + /// The DI asset identifier. + /// + public string? AssetId { get; init; } + + /// + /// The manufacturer name. + /// + public LocalizedText Manufacturer { get; init; } = LocalizedText.Null; + + /// + /// The manufacturer model name. + /// + public LocalizedText Model { get; init; } = LocalizedText.Null; + + /// + /// The manufacturer product code. + /// + public string? ProductCode { get; init; } + + /// + /// The manufacturer serial number. + /// + public string? SerialNumber { get; init; } + + /// + /// A URI or location for the component manual. + /// + public string? DeviceManual { get; init; } + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/RoboticsEngineeringValue.cs b/src/Opc.Ua.Robotics/Contracts/RoboticsEngineeringValue.cs new file mode 100644 index 0000000000..c1a8cbe9f7 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/RoboticsEngineeringValue.cs @@ -0,0 +1,47 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Engineering metadata for a measured or commanded scalar value. + /// + public sealed record RoboticsEngineeringValue + { + /// + /// The engineering unit advertised by the variable. + /// + public EUInformation? EngineeringUnits { get; init; } + + /// + /// The supported or advertised engineering range. + /// + public Range? Range { get; init; } + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/RoboticsRelationshipEntry.cs b/src/Opc.Ua.Robotics/Contracts/RoboticsRelationshipEntry.cs new file mode 100644 index 0000000000..5e5354a7d6 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/RoboticsRelationshipEntry.cs @@ -0,0 +1,57 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// An OPC 40010 relationship between two resolved instance NodeIds. + /// + public sealed record RoboticsRelationshipEntry + { + /// + /// The source instance NodeId. + /// + public NodeId SourceId { get; init; } = NodeId.Null; + + /// + /// The resolved ReferenceType NodeId. + /// + public NodeId ReferenceTypeId { get; init; } = NodeId.Null; + + /// + /// The target instance NodeId. + /// + public NodeId TargetId { get; init; } = NodeId.Null; + + /// + /// Whether the entry was observed in the inverse direction. + /// + public bool IsInverse { get; init; } + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/RoboticsRelationshipSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/RoboticsRelationshipSnapshot.cs new file mode 100644 index 0000000000..6be6f817a5 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/RoboticsRelationshipSnapshot.cs @@ -0,0 +1,72 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Categorized semantic relationships defined by the Robotics NodeSet. + /// + public sealed record RoboticsRelationshipSnapshot + { + /// + /// Controller-to-motion-device and task-control-to-motion-device Controls relationships. + /// + public ArrayOf Controls { get; init; } = []; + + /// + /// Axis-to-power-train Requires relationships. + /// + public ArrayOf Requires { get; init; } = []; + + /// + /// Power-train-to-axis Moves relationships. + /// + public ArrayOf Moves { get; init; } = []; + + /// + /// Motor-to-drive IsDrivenBy relationships. + /// + public ArrayOf IsDrivenBy { get; init; } = []; + + /// + /// Power-train HasSlave relationships. + /// + public ArrayOf HasSlave { get; init; } = []; + + /// + /// Symmetric IsConnectedTo relationships. + /// + public ArrayOf IsConnectedTo { get; init; } = []; + + /// + /// Controller-to-safety-state HasSafetyStates relationships. + /// + public ArrayOf HasSafetyStates { get; init; } = []; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/RoboticsTopologySnapshot.cs b/src/Opc.Ua.Robotics/Contracts/RoboticsTopologySnapshot.cs new file mode 100644 index 0000000000..c9a1a45427 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/RoboticsTopologySnapshot.cs @@ -0,0 +1,102 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Focused server-independent read projection of a discovered Robotics topology. + /// + public sealed record RoboticsTopologySnapshot + { + /// + /// Motion-device systems in the topology. + /// + public ArrayOf Systems { get; init; } = []; + + /// + /// Controllers in the topology. + /// + public ArrayOf Controllers { get; init; } = []; + + /// + /// Motion devices in the topology. + /// + public ArrayOf MotionDevices { get; init; } = []; + + /// + /// Axes in the topology. + /// + public ArrayOf Axes { get; init; } = []; + + /// + /// Loads in the topology. + /// + public ArrayOf Loads { get; init; } = []; + + /// + /// Power trains in the topology. + /// + public ArrayOf PowerTrains { get; init; } = []; + + /// + /// Motors in the topology. + /// + public ArrayOf Motors { get; init; } = []; + + /// + /// Gears in the topology. + /// + public ArrayOf Gears { get; init; } = []; + + /// + /// Drives in the topology. + /// + public ArrayOf Drives { get; init; } = []; + + /// + /// Safety states in the topology. + /// + public ArrayOf SafetyStates { get; init; } = []; + + /// + /// Task controls in the topology. + /// + public ArrayOf TaskControls { get; init; } = []; + + /// + /// Task modules in the topology. + /// + public ArrayOf TaskModules { get; init; } = []; + + /// + /// Semantic Robotics relationships observed in the topology. + /// + public RoboticsRelationshipSnapshot Relationships { get; init; } = new(); + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/SafetyFunctionSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/SafetyFunctionSnapshot.cs new file mode 100644 index 0000000000..4f0b2ef504 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/SafetyFunctionSnapshot.cs @@ -0,0 +1,57 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Snapshot of an emergency-stop or protective-stop function. + /// + public sealed record SafetyFunctionSnapshot + { + /// + /// The function instance NodeId. + /// + public NodeId NodeId { get; init; } = NodeId.Null; + + /// + /// The manufacturer-specific function name. + /// + public string? Name { get; init; } + + /// + /// The Active value, including status and timestamps. + /// + public DataValue Active { get; init; } = DataValue.Null; + + /// + /// The optional Enabled value, including status and timestamps. + /// + public DataValue Enabled { get; init; } = DataValue.Null; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/SafetyStateSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/SafetyStateSnapshot.cs new file mode 100644 index 0000000000..f6813362a3 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/SafetyStateSnapshot.cs @@ -0,0 +1,67 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a SafetyStateType instance. + /// + public sealed record SafetyStateSnapshot + { + /// + /// The safety-state identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + + /// + /// The EmergencyStop value, including status and timestamps. + /// + public DataValue EmergencyStop { get; init; } = DataValue.Null; + + /// + /// The OperationalMode value, including status and timestamps. + /// + public DataValue OperationalMode { get; init; } = DataValue.Null; + + /// + /// The ProtectiveStop value, including status and timestamps. + /// + public DataValue ProtectiveStop { get; init; } = DataValue.Null; + + /// + /// Emergency-stop function entries. + /// + public ArrayOf EmergencyStopFunctions { get; init; } = []; + + /// + /// Protective-stop function entries. + /// + public ArrayOf ProtectiveStopFunctions { get; init; } = []; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/TaskControlSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/TaskControlSnapshot.cs new file mode 100644 index 0000000000..7828564187 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/TaskControlSnapshot.cs @@ -0,0 +1,67 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a TaskControlType instance. + /// + public sealed record TaskControlSnapshot + { + /// + /// The task-control identification. + /// + public RoboticsComponentIdentification Identification { get; init; } = new(); + + /// + /// The optional execution mode, including status and timestamps. + /// + public DataValue ExecutionMode { get; init; } = DataValue.Null; + + /// + /// Whether a task program is loaded, including status and timestamps. + /// + public DataValue TaskProgramLoaded { get; init; } = DataValue.Null; + + /// + /// The loaded task-program name, including status and timestamps. + /// + public DataValue TaskProgramName { get; init; } = DataValue.Null; + + /// + /// The TaskControlOperation instance, or when absent. + /// + public NodeId TaskControlOperationId { get; init; } = NodeId.Null; + + /// + /// Task modules exposed by this task control. + /// + public ArrayOf TaskModuleIds { get; init; } = []; + } +} diff --git a/src/Opc.Ua.Robotics/Contracts/TaskModuleSnapshot.cs b/src/Opc.Ua.Robotics/Contracts/TaskModuleSnapshot.cs new file mode 100644 index 0000000000..20290382a7 --- /dev/null +++ b/src/Opc.Ua.Robotics/Contracts/TaskModuleSnapshot.cs @@ -0,0 +1,57 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Read-model snapshot of a TaskModuleType instance. + /// + public sealed record TaskModuleSnapshot + { + /// + /// The task-module instance NodeId. + /// + public NodeId NodeId { get; init; } = NodeId.Null; + + /// + /// The task-module name. + /// + public string? Name { get; init; } + + /// + /// The task-module version. + /// + public string? Version { get; init; } + + /// + /// Whether another task module references this module. + /// + public DataValue IsReferenced { get; init; } = DataValue.Null; + } +} diff --git a/src/Opc.Ua.Robotics/Model/Opc.Ua.IA.NodeSet2.xml b/src/Opc.Ua.Robotics/Model/Opc.Ua.IA.NodeSet2.xml new file mode 100644 index 0000000000..8d166e4c92 --- /dev/null +++ b/src/Opc.Ua.Robotics/Model/Opc.Ua.IA.NodeSet2.xml @@ -0,0 +1,1782 @@ + + + + + + http://opcfoundation.org/UA/IA/ + http://opcfoundation.org/UA/DI/ + + + + + + + + + i=1 + i=3 + i=6 + i=7 + i=10 + i=11 + i=12 + i=13 + i=15 + i=21 + i=26 + i=28 + i=37 + i=38 + i=39 + i=40 + i=45 + i=46 + i=47 + i=49 + i=256 + i=290 + i=291 + i=294 + i=884 + i=887 + i=7594 + i=16307 + i=17603 + ns=2;i=6244 + ns=1;i=3002 + ns=1;i=3003 + ns=1;i=3004 + ns=1;i=3005 + ns=1;i=3007 + + + + + + + + LevelDisplayMode + Contains the values used to indicate how a percentual value is displayed if the stacklight unit works in Levelmeter mode. + https://reference.opcfoundation.org/IA/v101/docs/5.3.2 + + i=29 + ns=1;i=6001 + + + + Uses dimming to display fractions. + + + Uses blinking to display fractions. + + + Display fractions in a way not defined in this version of the specification. + + + + + EnumValues + + i=68 + ns=1;i=3003 + + + + + + i=7616 + + + + 0 + + Dimmed + + + en + Uses dimming to display fractions. + + + + + + + i=7616 + + + + 1 + + Blinking + + + en + Uses blinking to display fractions. + + + + + + + i=7616 + + + + 2 + + Other + + + en + Display fractions in a way not defined in this version of the specification. + + + + + + + + + SignalColor + Holds the possible colour values for stacklight lamps. + https://reference.opcfoundation.org/IA/v101/docs/5.3.3 + + i=29 + ns=1;i=6007 + + + + Element is disabled. + + + This value indicates a red lamp colour. + + + This value indicates a green lamp colour. + + + This value indicates a blue lamp colour. + + + This value indicates a yellow lamp colour (R+G). + + + This value indicates a purple lamp colour (R+B). + + + This value indicates a cyan lamp colour (G+B). + + + This value indicates a white lamp colour (R+G+B). + + + + + EnumValues + + i=68 + ns=1;i=3004 + + + + + + i=7616 + + + + 0 + + Off + + + en + Element is disabled. + + + + + + + i=7616 + + + + 1 + + Red + + + en + This value indicates a red lamp colour. + + + + + + + i=7616 + + + + 2 + + Green + + + en + This value indicates a green lamp colour. + + + + + + + i=7616 + + + + 3 + + Blue + + + en + This value indicates a blue lamp colour. + + + + + + + i=7616 + + + + 4 + + Yellow + + + en + This value indicates a yellow lamp colour (R+G). + + + + + + + i=7616 + + + + 5 + + Purple + + + en + This value indicates a purple lamp colour (R+B). + + + + + + + i=7616 + + + + 6 + + Cyan + + + en + This value indicates a cyan lamp colour (G+B). + + + + + + + i=7616 + + + + 7 + + White + + + en + This value indicates a white lamp colour (R+G+B). + + + + + + + + + SignalModeLight + Contains the values used to indicate in what way a lamp behaves when switched on. + https://reference.opcfoundation.org/IA/v101/docs/5.3.4 + + i=29 + ns=1;i=6008 + + + + This value indicates a continuous light. + + + This value indicates a blinking light (blinking in regular intervals with equally long on and off times). + + + This value indicates a flashing light (blinking in intervals with longer off times than on times, per interval multiple on times are possible). + + + The light is handled in a way not defined in this version of the specification. + + + + + EnumValues + + i=68 + ns=1;i=3005 + + + + + + i=7616 + + + + 0 + + Continuous + + + en + This value indicates a continuous light. + + + + + + + i=7616 + + + + 1 + + Blinking + + + en + This value indicates a blinking light (blinking in regular intervals with equally long on and off times). + + + + + + + i=7616 + + + + 2 + + Flashing + + + en + This value indicates a flashing light (blinking in intervals with longer off times than on times, per interval multiple on times are possible). + + + + + + + i=7616 + + + + 3 + + Other + + + en + The light is handled in a way not defined in this version of the specification. + + + + + + + + + StacklightOperationMode + Contains the values used to indicate how a stacklight (as a whole unit) is used. + https://reference.opcfoundation.org/IA/v101/docs/5.3.1 + + i=29 + ns=1;i=6006 + + + + Stacklight is used as stack of individual lights + + + Stacklight is used as level meter + + + The whole stack acts as a running light + + + Stacklight is used in a way not defined in this version of the specification + + + + + EnumValues + + i=68 + ns=1;i=3002 + + + + + + i=7616 + + + + 0 + + Segmented + + + en + Stacklight is used as stack of individual lights + + + + + + + i=7616 + + + + 1 + + Levelmeter + + + en + Stacklight is used as level meter + + + + + + + i=7616 + + + + 2 + + Running_Light + + + en + The whole stack acts as a running light + + + + + + + i=7616 + + + + 3 + + Other + + + en + Stacklight is used in a way not defined in this version of the specification + + + + + + + + + RGBWDataType + https://reference.opcfoundation.org/IA/v101/docs/5.3.5 + + i=22 + ns=1;i=5009 + ns=1;i=5014 + + + + Defines the intensity of the colour red + + + Defines the intensity of the colour green + + + Defines the intensity of the colour blue + + + Defines the intensity of an additional white component. Shall be not provided when using RGB. + + + + + RGBWDataType + + i=69 + ns=1;i=5009 + ns=1;i=6002 + + + RGBWDataType + + + + RGBWDataType + + i=69 + ns=1;i=5014 + ns=1;i=6004 + + + //xs:element[@name='RGBWDataType'] + + + + TypeDictionary + Collects the data type descriptions of http://opcfoundation.org/UA/IA + + i=72 + ns=1;i=6053 + ns=1;i=6003 + i=93 + ns=1;i=6050 + + + PG9wYzpUeXBlRGljdGlvbmFyeSB4bWxuczpvcGM9Imh0dHA6Ly9vcGNmb3VuZGF0aW9uLm9yZy9CaW5hc + nlTY2hlbWEvIiBEZWZhdWx0Qnl0ZU9yZGVyPSJMaXR0bGVFbmRpYW4iIFRhcmdldE5hbWVzc + GFjZT0iaHR0cDovL29wY2ZvdW5kYXRpb24ub3JnL1VBL0lBLyIgeG1sbnM6eHNpPSJodHRwO + i8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeG1sbnM6dWE9Imh0dHA6L + y9vcGNmb3VuZGF0aW9uLm9yZy9VQS8iIHhtbG5zOnRucz0iaHR0cDovL29wY2ZvdW5kYXRpb + 24ub3JnL1VBL0lBLyI+CiA8b3BjOkltcG9ydCBOYW1lc3BhY2U9Imh0dHA6Ly9vcGNmb3VuZ + GF0aW9uLm9yZy9VQS8iLz4KIDxvcGM6U3RydWN0dXJlZFR5cGUgQmFzZVR5cGU9InVhOkV4d + GVuc2lvbk9iamVjdCIgTmFtZT0iUkdCV0RhdGFUeXBlIj4KICA8b3BjOkZpZWxkIE5hbWU9I + ldoaXRlU3BlY2lmaWVkIiBUeXBlTmFtZT0ib3BjOkJpdCIvPgogIDxvcGM6RmllbGQgTGVuZ + 3RoPSIzMSIgTmFtZT0iUmVzZXJ2ZWQxIiBUeXBlTmFtZT0ib3BjOkJpdCIvPgogIDxvcGM6R + mllbGQgTmFtZT0iUmVkIiBUeXBlTmFtZT0ib3BjOkJ5dGUiLz4KICA8b3BjOkZpZWxkIE5hb + WU9IkdyZWVuIiBUeXBlTmFtZT0ib3BjOkJ5dGUiLz4KICA8b3BjOkZpZWxkIE5hbWU9IkJsd + WUiIFR5cGVOYW1lPSJvcGM6Qnl0ZSIvPgogIDxvcGM6RmllbGQgU3dpdGNoRmllbGQ9Ildoa + XRlU3BlY2lmaWVkIiBOYW1lPSJXaGl0ZSIgVHlwZU5hbWU9Im9wYzpCeXRlIi8+CiA8L29wY + zpTdHJ1Y3R1cmVkVHlwZT4KIDxvcGM6RW51bWVyYXRlZFR5cGUgTGVuZ3RoSW5CaXRzPSIzM + iIgTmFtZT0iTGV2ZWxEaXNwbGF5TW9kZSI+CiAgPG9wYzpEb2N1bWVudGF0aW9uPkNvbnRha + W5zIHRoZSB2YWx1ZXMgdXNlZCB0byBpbmRpY2F0ZSBob3cgYSBwZXJjZW50dWFsIHZhbHVlI + GlzIGRpc3BsYXllZCBpZiB0aGUgc3RhY2tsaWdodCB1bml0IHdvcmtzIGluIExldmVsbWV0Z + XIgbW9kZS48L29wYzpEb2N1bWVudGF0aW9uPgogIDxvcGM6RW51bWVyYXRlZFZhbHVlIFZhb + HVlPSIwIiBOYW1lPSJEaW1tZWQiLz4KICA8b3BjOkVudW1lcmF0ZWRWYWx1ZSBWYWx1ZT0iM + SIgTmFtZT0iQmxpbmtpbmciLz4KICA8b3BjOkVudW1lcmF0ZWRWYWx1ZSBWYWx1ZT0iMiIgT + mFtZT0iT3RoZXIiLz4KIDwvb3BjOkVudW1lcmF0ZWRUeXBlPgogPG9wYzpFbnVtZXJhdGVkV + HlwZSBMZW5ndGhJbkJpdHM9IjMyIiBOYW1lPSJTaWduYWxDb2xvciI+CiAgPG9wYzpEb2N1b + WVudGF0aW9uPkhvbGRzIHRoZSBwb3NzaWJsZSBjb2xvdXIgdmFsdWVzIGZvciBzdGFja2xpZ + 2h0IGxhbXBzLjwvb3BjOkRvY3VtZW50YXRpb24+CiAgPG9wYzpFbnVtZXJhdGVkVmFsdWUgV + mFsdWU9IjAiIE5hbWU9Ik9mZiIvPgogIDxvcGM6RW51bWVyYXRlZFZhbHVlIFZhbHVlPSIxI + iBOYW1lPSJSZWQiLz4KICA8b3BjOkVudW1lcmF0ZWRWYWx1ZSBWYWx1ZT0iMiIgTmFtZT0iR + 3JlZW4iLz4KICA8b3BjOkVudW1lcmF0ZWRWYWx1ZSBWYWx1ZT0iMyIgTmFtZT0iQmx1ZSIvP + gogIDxvcGM6RW51bWVyYXRlZFZhbHVlIFZhbHVlPSI0IiBOYW1lPSJZZWxsb3ciLz4KICA8b + 3BjOkVudW1lcmF0ZWRWYWx1ZSBWYWx1ZT0iNSIgTmFtZT0iUHVycGxlIi8+CiAgPG9wYzpFb + nVtZXJhdGVkVmFsdWUgVmFsdWU9IjYiIE5hbWU9IkN5YW4iLz4KICA8b3BjOkVudW1lcmF0Z + WRWYWx1ZSBWYWx1ZT0iNyIgTmFtZT0iV2hpdGUiLz4KIDwvb3BjOkVudW1lcmF0ZWRUeXBlP + gogPG9wYzpFbnVtZXJhdGVkVHlwZSBMZW5ndGhJbkJpdHM9IjMyIiBOYW1lPSJTaWduYWxNb + 2RlTGlnaHQiPgogIDxvcGM6RG9jdW1lbnRhdGlvbj5Db250YWlucyB0aGUgdmFsdWVzIHVzZ + WQgdG8gaW5kaWNhdGUgaW4gd2hhdCB3YXkgYSBsYW1wIGJlaGF2ZXMgd2hlbiBzd2l0Y2hlZ + CBvbi48L29wYzpEb2N1bWVudGF0aW9uPgogIDxvcGM6RW51bWVyYXRlZFZhbHVlIFZhbHVlP + SIwIiBOYW1lPSJDb250aW51b3VzIi8+CiAgPG9wYzpFbnVtZXJhdGVkVmFsdWUgVmFsdWU9I + jEiIE5hbWU9IkJsaW5raW5nIi8+CiAgPG9wYzpFbnVtZXJhdGVkVmFsdWUgVmFsdWU9IjIiI + E5hbWU9IkZsYXNoaW5nIi8+CiAgPG9wYzpFbnVtZXJhdGVkVmFsdWUgVmFsdWU9IjMiIE5hb + WU9Ik90aGVyIi8+CiA8L29wYzpFbnVtZXJhdGVkVHlwZT4KIDxvcGM6RW51bWVyYXRlZFR5c + GUgTGVuZ3RoSW5CaXRzPSIzMiIgTmFtZT0iU3RhY2tsaWdodE9wZXJhdGlvbk1vZGUiPgogI + DxvcGM6RG9jdW1lbnRhdGlvbj5Db250YWlucyB0aGUgdmFsdWVzIHVzZWQgdG8gaW5kaWNhd + GUgaG93IGEgc3RhY2tsaWdodCAoYXMgYSB3aG9sZSB1bml0KSBpcyB1c2VkLjwvb3BjOkRvY + 3VtZW50YXRpb24+CiAgPG9wYzpFbnVtZXJhdGVkVmFsdWUgVmFsdWU9IjAiIE5hbWU9IlNlZ + 21lbnRlZCIvPgogIDxvcGM6RW51bWVyYXRlZFZhbHVlIFZhbHVlPSIxIiBOYW1lPSJMZXZlb + G1ldGVyIi8+CiAgPG9wYzpFbnVtZXJhdGVkVmFsdWUgVmFsdWU9IjIiIE5hbWU9IlJ1bm5pb + mdfTGlnaHQiLz4KICA8b3BjOkVudW1lcmF0ZWRWYWx1ZSBWYWx1ZT0iMyIgTmFtZT0iT3RoZ + XIiLz4KIDwvb3BjOkVudW1lcmF0ZWRUeXBlPgo8L29wYzpUeXBlRGljdGlvbmFyeT4K + + + + Deprecated + Indicates that all of the DataType definitions represented by the DataTypeDictionaryType are available through a DataTypeDefinition Attribute. + + i=68 + ns=1;i=6002 + + + true + + + + NamespaceUri + + i=68 + ns=1;i=6002 + + + http://opcfoundation.org/UA/IA/ + + + + TypeDictionary + Collects the data type descriptions of http://opcfoundation.org/UA/IA + + i=72 + ns=1;i=6054 + ns=1;i=6005 + i=92 + ns=1;i=6051 + + + PHhzOnNjaGVtYSBlbGVtZW50Rm9ybURlZmF1bHQ9InF1YWxpZmllZCIgeG1sbnM6dWE9Imh0dHA6Ly9vc + GNmb3VuZGF0aW9uLm9yZy9VQS8yMDA4LzAyL1R5cGVzLnhzZCIgeG1sbnM6dG5zPSJodHRwO + i8vb3BjZm91bmRhdGlvbi5vcmcvVUEvSUEvVHlwZXMueHNkIiB4bWxuczp4cz0iaHR0cDovL + 3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIHRhcmdldE5hbWVzcGFjZT0iaHR0cDovL29wY + 2ZvdW5kYXRpb24ub3JnL1VBL0lBL1R5cGVzLnhzZCI+CiA8eHM6aW1wb3J0IG5hbWVzcGFjZ + T0iaHR0cDovL29wY2ZvdW5kYXRpb24ub3JnL1VBLzIwMDgvMDIvVHlwZXMueHNkIi8+CiA8e + HM6c2ltcGxlVHlwZSBuYW1lPSJMZXZlbERpc3BsYXlNb2RlIj4KICA8eHM6YW5ub3RhdGlvb + j4KICAgPHhzOmRvY3VtZW50YXRpb24+Q29udGFpbnMgdGhlIHZhbHVlcyB1c2VkIHRvIGluZ + GljYXRlIGhvdyBhIHBlcmNlbnR1YWwgdmFsdWUgaXMgZGlzcGxheWVkIGlmIHRoZSBzdGFja + 2xpZ2h0IHVuaXQgd29ya3MgaW4gTGV2ZWxtZXRlciBtb2RlLjwveHM6ZG9jdW1lbnRhdGlvb + j4KICA8L3hzOmFubm90YXRpb24+CiAgPHhzOnJlc3RyaWN0aW9uIGJhc2U9InhzOnN0cmluZ + yI+CiAgIDx4czplbnVtZXJhdGlvbiB2YWx1ZT0iRGltbWVkXzAiLz4KICAgPHhzOmVudW1lc + mF0aW9uIHZhbHVlPSJCbGlua2luZ18xIi8+CiAgIDx4czplbnVtZXJhdGlvbiB2YWx1ZT0iT + 3RoZXJfMiIvPgogIDwveHM6cmVzdHJpY3Rpb24+CiA8L3hzOnNpbXBsZVR5cGU+CiA8eHM6Z + WxlbWVudCB0eXBlPSJ0bnM6TGV2ZWxEaXNwbGF5TW9kZSIgbmFtZT0iTGV2ZWxEaXNwbGF5T + W9kZSIvPgogPHhzOmNvbXBsZXhUeXBlIG5hbWU9Ikxpc3RPZkxldmVsRGlzcGxheU1vZGUiP + gogIDx4czpzZXF1ZW5jZT4KICAgPHhzOmVsZW1lbnQgdHlwZT0idG5zOkxldmVsRGlzcGxhe + U1vZGUiIG5pbGxhYmxlPSJ0cnVlIiBuYW1lPSJMZXZlbERpc3BsYXlNb2RlIiBtaW5PY2N1c + nM9IjAiIG1heE9jY3Vycz0idW5ib3VuZGVkIi8+CiAgPC94czpzZXF1ZW5jZT4KIDwveHM6Y + 29tcGxleFR5cGU+CiA8eHM6ZWxlbWVudCB0eXBlPSJ0bnM6TGlzdE9mTGV2ZWxEaXNwbGF5T + W9kZSIgbmlsbGFibGU9InRydWUiIG5hbWU9Ikxpc3RPZkxldmVsRGlzcGxheU1vZGUiLz4KI + Dx4czpzaW1wbGVUeXBlIG5hbWU9IlNpZ25hbENvbG9yIj4KICA8eHM6YW5ub3RhdGlvbj4KI + CAgPHhzOmRvY3VtZW50YXRpb24+SG9sZHMgdGhlIHBvc3NpYmxlIGNvbG91ciB2YWx1ZXMgZ + m9yIHN0YWNrbGlnaHQgbGFtcHMuPC94czpkb2N1bWVudGF0aW9uPgogIDwveHM6YW5ub3Rhd + Glvbj4KICA8eHM6cmVzdHJpY3Rpb24gYmFzZT0ieHM6c3RyaW5nIj4KICAgPHhzOmVudW1lc + mF0aW9uIHZhbHVlPSJPZmZfMCIvPgogICA8eHM6ZW51bWVyYXRpb24gdmFsdWU9IlJlZF8xI + i8+CiAgIDx4czplbnVtZXJhdGlvbiB2YWx1ZT0iR3JlZW5fMiIvPgogICA8eHM6ZW51bWVyY + XRpb24gdmFsdWU9IkJsdWVfMyIvPgogICA8eHM6ZW51bWVyYXRpb24gdmFsdWU9IlllbGxvd + 180Ii8+CiAgIDx4czplbnVtZXJhdGlvbiB2YWx1ZT0iUHVycGxlXzUiLz4KICAgPHhzOmVud + W1lcmF0aW9uIHZhbHVlPSJDeWFuXzYiLz4KICAgPHhzOmVudW1lcmF0aW9uIHZhbHVlPSJXa + Gl0ZV83Ii8+CiAgPC94czpyZXN0cmljdGlvbj4KIDwveHM6c2ltcGxlVHlwZT4KIDx4czplb + GVtZW50IHR5cGU9InRuczpTaWduYWxDb2xvciIgbmFtZT0iU2lnbmFsQ29sb3IiLz4KIDx4c + zpjb21wbGV4VHlwZSBuYW1lPSJMaXN0T2ZTaWduYWxDb2xvciI+CiAgPHhzOnNlcXVlbmNlP + gogICA8eHM6ZWxlbWVudCB0eXBlPSJ0bnM6U2lnbmFsQ29sb3IiIG5pbGxhYmxlPSJ0cnVlI + iBuYW1lPSJTaWduYWxDb2xvciIgbWluT2NjdXJzPSIwIiBtYXhPY2N1cnM9InVuYm91bmRlZ + CIvPgogIDwveHM6c2VxdWVuY2U+CiA8L3hzOmNvbXBsZXhUeXBlPgogPHhzOmVsZW1lbnQgd + HlwZT0idG5zOkxpc3RPZlNpZ25hbENvbG9yIiBuaWxsYWJsZT0idHJ1ZSIgbmFtZT0iTGlzd + E9mU2lnbmFsQ29sb3IiLz4KIDx4czpzaW1wbGVUeXBlIG5hbWU9IlNpZ25hbE1vZGVMaWdod + CI+CiAgPHhzOmFubm90YXRpb24+CiAgIDx4czpkb2N1bWVudGF0aW9uPkNvbnRhaW5zIHRoZ + SB2YWx1ZXMgdXNlZCB0byBpbmRpY2F0ZSBpbiB3aGF0IHdheSBhIGxhbXAgYmVoYXZlcyB3a + GVuIHN3aXRjaGVkIG9uLjwveHM6ZG9jdW1lbnRhdGlvbj4KICA8L3hzOmFubm90YXRpb24+C + iAgPHhzOnJlc3RyaWN0aW9uIGJhc2U9InhzOnN0cmluZyI+CiAgIDx4czplbnVtZXJhdGlvb + iB2YWx1ZT0iQ29udGludW91c18wIi8+CiAgIDx4czplbnVtZXJhdGlvbiB2YWx1ZT0iQmxpb + mtpbmdfMSIvPgogICA8eHM6ZW51bWVyYXRpb24gdmFsdWU9IkZsYXNoaW5nXzIiLz4KICAgP + HhzOmVudW1lcmF0aW9uIHZhbHVlPSJPdGhlcl8zIi8+CiAgPC94czpyZXN0cmljdGlvbj4KI + DwveHM6c2ltcGxlVHlwZT4KIDx4czplbGVtZW50IHR5cGU9InRuczpTaWduYWxNb2RlTGlna + HQiIG5hbWU9IlNpZ25hbE1vZGVMaWdodCIvPgogPHhzOmNvbXBsZXhUeXBlIG5hbWU9Ikxpc + 3RPZlNpZ25hbE1vZGVMaWdodCI+CiAgPHhzOnNlcXVlbmNlPgogICA8eHM6ZWxlbWVudCB0e + XBlPSJ0bnM6U2lnbmFsTW9kZUxpZ2h0IiBuaWxsYWJsZT0idHJ1ZSIgbmFtZT0iU2lnbmFsT + W9kZUxpZ2h0IiBtaW5PY2N1cnM9IjAiIG1heE9jY3Vycz0idW5ib3VuZGVkIi8+CiAgPC94c + zpzZXF1ZW5jZT4KIDwveHM6Y29tcGxleFR5cGU+CiA8eHM6ZWxlbWVudCB0eXBlPSJ0bnM6T + GlzdE9mU2lnbmFsTW9kZUxpZ2h0IiBuaWxsYWJsZT0idHJ1ZSIgbmFtZT0iTGlzdE9mU2lnb + mFsTW9kZUxpZ2h0Ii8+CiA8eHM6c2ltcGxlVHlwZSBuYW1lPSJTdGFja2xpZ2h0T3BlcmF0a + W9uTW9kZSI+CiAgPHhzOmFubm90YXRpb24+CiAgIDx4czpkb2N1bWVudGF0aW9uPkNvbnRha + W5zIHRoZSB2YWx1ZXMgdXNlZCB0byBpbmRpY2F0ZSBob3cgYSBzdGFja2xpZ2h0IChhcyBhI + Hdob2xlIHVuaXQpIGlzIHVzZWQuPC94czpkb2N1bWVudGF0aW9uPgogIDwveHM6YW5ub3Rhd + Glvbj4KICA8eHM6cmVzdHJpY3Rpb24gYmFzZT0ieHM6c3RyaW5nIj4KICAgPHhzOmVudW1lc + mF0aW9uIHZhbHVlPSJTZWdtZW50ZWRfMCIvPgogICA8eHM6ZW51bWVyYXRpb24gdmFsdWU9I + kxldmVsbWV0ZXJfMSIvPgogICA8eHM6ZW51bWVyYXRpb24gdmFsdWU9IlJ1bm5pbmdfTGlna + HRfMiIvPgogICA8eHM6ZW51bWVyYXRpb24gdmFsdWU9Ik90aGVyXzMiLz4KICA8L3hzOnJlc + 3RyaWN0aW9uPgogPC94czpzaW1wbGVUeXBlPgogPHhzOmVsZW1lbnQgdHlwZT0idG5zOlN0Y + WNrbGlnaHRPcGVyYXRpb25Nb2RlIiBuYW1lPSJTdGFja2xpZ2h0T3BlcmF0aW9uTW9kZSIvP + gogPHhzOmNvbXBsZXhUeXBlIG5hbWU9Ikxpc3RPZlN0YWNrbGlnaHRPcGVyYXRpb25Nb2RlI + j4KICA8eHM6c2VxdWVuY2U+CiAgIDx4czplbGVtZW50IHR5cGU9InRuczpTdGFja2xpZ2h0T + 3BlcmF0aW9uTW9kZSIgbmlsbGFibGU9InRydWUiIG5hbWU9IlN0YWNrbGlnaHRPcGVyYXRpb + 25Nb2RlIiBtaW5PY2N1cnM9IjAiIG1heE9jY3Vycz0idW5ib3VuZGVkIi8+CiAgPC94czpzZ + XF1ZW5jZT4KIDwveHM6Y29tcGxleFR5cGU+CiA8eHM6ZWxlbWVudCB0eXBlPSJ0bnM6TGlzd + E9mU3RhY2tsaWdodE9wZXJhdGlvbk1vZGUiIG5pbGxhYmxlPSJ0cnVlIiBuYW1lPSJMaXN0T + 2ZTdGFja2xpZ2h0T3BlcmF0aW9uTW9kZSIvPgogPHhzOmNvbXBsZXhUeXBlIG5hbWU9IlJHQ + ldEYXRhVHlwZSI+CiAgPHhzOnNlcXVlbmNlPgogICA8eHM6ZWxlbWVudCB0eXBlPSJ4czp1b + nNpZ25lZEludCIgbmFtZT0iRW5jb2RpbmdNYXNrIiBtaW5PY2N1cnM9IjAiLz4KICAgPHhzO + mVsZW1lbnQgdHlwZT0ieHM6dW5zaWduZWRCeXRlIiBuYW1lPSJSZWQiIG1pbk9jY3Vycz0iM + CIgbWF4T2NjdXJzPSIxIi8+CiAgIDx4czplbGVtZW50IHR5cGU9InhzOnVuc2lnbmVkQnl0Z + SIgbmFtZT0iR3JlZW4iIG1pbk9jY3Vycz0iMCIgbWF4T2NjdXJzPSIxIi8+CiAgIDx4czplb + GVtZW50IHR5cGU9InhzOnVuc2lnbmVkQnl0ZSIgbmFtZT0iQmx1ZSIgbWluT2NjdXJzPSIwI + iBtYXhPY2N1cnM9IjEiLz4KICAgPHhzOmVsZW1lbnQgdHlwZT0ieHM6dW5zaWduZWRCeXRlI + iBuYW1lPSJXaGl0ZSIgbWluT2NjdXJzPSIwIiBtYXhPY2N1cnM9IjEiLz4KICA8L3hzOnNlc + XVlbmNlPgogPC94czpjb21wbGV4VHlwZT4KIDx4czplbGVtZW50IHR5cGU9InRuczpSR0JXR + GF0YVR5cGUiIG5hbWU9IlJHQldEYXRhVHlwZSIvPgogPHhzOmNvbXBsZXhUeXBlIG5hbWU9I + kxpc3RPZlJHQldEYXRhVHlwZSI+CiAgPHhzOnNlcXVlbmNlPgogICA8eHM6ZWxlbWVudCB0e + XBlPSJ0bnM6UkdCV0RhdGFUeXBlIiBuaWxsYWJsZT0idHJ1ZSIgbmFtZT0iUkdCV0RhdGFUe + XBlIiBtaW5PY2N1cnM9IjAiIG1heE9jY3Vycz0idW5ib3VuZGVkIi8+CiAgPC94czpzZXF1Z + W5jZT4KIDwveHM6Y29tcGxleFR5cGU+CiA8eHM6ZWxlbWVudCB0eXBlPSJ0bnM6TGlzdE9mU + kdCV0RhdGFUeXBlIiBuaWxsYWJsZT0idHJ1ZSIgbmFtZT0iTGlzdE9mUkdCV0RhdGFUeXBlI + i8+CjwveHM6c2NoZW1hPgo= + + + + Deprecated + Indicates that all of the DataType definitions represented by the DataTypeDictionaryType are available through a DataTypeDefinition Attribute. + + i=68 + ns=1;i=6004 + + + true + + + + NamespaceUri + + i=68 + ns=1;i=6004 + + + http://opcfoundation.org/UA/IA/Types.xsd + + + + HasStatisticComponent + References of this type link Variables managing statistical data either directly or indirectly to an Object or ObjectType implementing the IStatisticsType interface. + https://reference.opcfoundation.org/IA/v101/docs/6.3.1 + + i=47 + + StatisticComponentOf + + + HasReferenceMeasurementInstrument + Relates the source node to a reference measurement instrument, like for example a calibration target using a reference measurement instrument. + https://reference.opcfoundation.org/IA/v101/docs/7.6.1 + + i=32 + + ReferenceMeasurementInstrumentOf + + + CalibrationValueType + Represents the specific quantity and value (with engineering unit) that a calibration target provides for calibration of equipment. + https://reference.opcfoundation.org/IA/v101/docs/7.5.1 + + i=2365 + ns=1;i=6057 + + + + EngineeringUnits + + i=68 + i=78 + ns=1;i=2002 + + + + CapacityRangeType + Represent a scale of calibration values. The value defines the range (lowest and highest value), and the resolution property the size of each step. + https://reference.opcfoundation.org/IA/v101/docs/7.5.2 + + i=2365 + ns=1;i=6058 + ns=1;i=6059 + + + + EngineeringUnits + + i=68 + i=78 + ns=1;i=2003 + + + + Resolution + + i=68 + i=78 + ns=1;i=2003 + + + + AcousticSignalType + Represents an acoustic signal. + https://reference.opcfoundation.org/IA/v101/docs/5.2.9 + + i=58 + ns=1;i=6029 + i=23513 + ns=1;i=6028 + + + + AudioSample + Contains the audio data, e.g. for devices capable of audio playback. + + i=80 + i=63 + ns=1;i=1009 + + + + NumberInList + Enumerate the acoustic signals. Instances of StackElementAcousticType index into this number using the OperationMode Property. + + i=68 + i=78 + ns=1;i=1009 + + + + BaseCalibrationTargetCategoryType + Abstract base type for categorizing calibration targets. Subtypes define the concrete categories. + https://reference.opcfoundation.org/IA/v101/docs/7.4.2 + + i=58 + + + + DynamicCalibrationTargetCategoryType + Characterizes a calibration target to be used together with a measurement instrument, that determines the values to be calibrated. It can be a piece created during the normal production process or an item specifically created for calibration purposes. The calibration target represents an individual piece or item, that is, if a new piece should be used or item is created, a new Object of this ObjectType is created. + https://reference.opcfoundation.org/IA/v101/docs/7.4.6 + + ns=1;i=1014 + + + + OneTimeCalibrationTargetCategoryType + Categorizes a calibration target to be used only once, for example because the calibration destroys the target. Typically, Objects of this ObjectType do not represent one individual calibration target, but a batch of calibration targets with the same characteristics. + https://reference.opcfoundation.org/IA/v101/docs/7.4.5 + + ns=1;i=1014 + + + + ReusableCalibrationTargetCategoryType + Categorizes a calibration target to be reused several times. For example, a calibration target like a meter, that is bought specifically for calibration and not destroyed by an individual usage is of this category. + https://reference.opcfoundation.org/IA/v101/docs/7.4.3 + + ns=1;i=1014 + + + + ReusableDeviceCalibrationTargetCategoryType + Categorizes a calibration target to be a reusable device that produces a certain environment like pressure that can be used for calibration. + https://reference.opcfoundation.org/IA/v101/docs/7.4.4 + + ns=1;i=1015 + + + + IStatisticsType + Base interface for managing statistical data. + https://reference.opcfoundation.org/IA/v101/docs/6.2.1/#6.2.1.1 + + i=17602 + ns=1;i=7001 + ns=1;i=6046 + + + + ResetStatistics + Restarts all statistical data, including a reset of the StartTime to the current time. + https://reference.opcfoundation.org/IA/v101/docs/6.2.1/#6.2.1.2 + + i=80 + ns=1;i=1011 + + + + StartTime + Indicates the point in time at which the collection of the statistical data has been started. + + i=68 + i=80 + ns=1;i=1011 + + + + IAggregateStatisticsType + Base interface for managing statistical data that is not rolled over. All data from the start of tracking the statistical data are considered, until the tracking gets reset. + https://reference.opcfoundation.org/IA/v101/docs/6.2.2 + + ns=1;i=1011 + ns=1;i=6047 + + + + ResetCondition + The reason and context for the reset of the statistics, which is done without a trigger from an OPC UA Client, like calling the ResetStatistics Method. ResetCondition is a vendor-specific, human readable string. ResetCondition is non-localized and might contain an expression that can be parsed by certain clients. Examples are: โ€œAFTER 4 HOURSโ€, โ€œAFTER 1000 ITEMSโ€, โ€œOPERATORโ€. โ€œOPERATORโ€ means, that an operator resets the statistics on a local HMI. + + i=68 + i=80 + ns=1;i=1012 + + + + IRollingStatisticsType + Base interface for managing statistical data that is rolled over, i.e. only a certain amount of data is considered for statistical data. + https://reference.opcfoundation.org/IA/v101/docs/6.2.3 + + ns=1;i=1011 + ns=1;i=6048 + ns=1;i=6049 + + + + WindowDuration + The duration after the statistical data are rolled over. Only the data that were gathered during that duration are considered for the statistical data, even if the time interval between the StartTime and the current time is longer. + + i=68 + i=80 + ns=1;i=1013 + + + + WindowNumberOfValues + The number of values before the data gets rolled over. For the statistical data, only the data fitting into the number of values is considered, even if more data were gathered since StartTime. + + i=68 + i=80 + ns=1;i=1013 + + + + CalibrationTargetType + Provides information about a calibration target. + https://reference.opcfoundation.org/IA/v101/docs/7.4.1 + + i=58 + ns=1;i=5011 + ns=1;i=5013 + ns=1;i=6063 + ns=1;i=5010 + ns=1;i=6060 + ns=1;i=6061 + ns=1;i=5012 + ns=1;i=6062 + + + + CalibrationTargetCategory + Defines what category the calibration target is of. + + i=78 + ns=1;i=1014 + ns=1;i=1019 + + + + CalibrationTargetFeatures + A folder containing information about the features of a calibration target, that is, what can be calibrated with the calibration target. + + i=78 + i=61 + ns=1;i=1019 + ns=1;i=6064 + ns=1;i=6066 + + + + <CalibrationValue> + A calibration value indicates the value the calibration target provides for calibration and includes its quantity and engineering unit. + + i=11508 + ns=1;i=2002 + ns=1;i=5013 + ns=1;i=6065 + + + + EngineeringUnits + + i=68 + i=78 + ns=1;i=6064 + + + + <CapacityRange> + A capacity range indicates a range (low and high value) as well as a resolution, and thus defines a number of values the calibration target provides for calibration and includes the quantity and engineering unit. + + i=11508 + ns=1;i=2003 + ns=1;i=5013 + ns=1;i=6067 + ns=1;i=6068 + + + + EngineeringUnits + + i=68 + i=78 + ns=1;i=6066 + + + + Resolution + + i=68 + i=78 + ns=1;i=6066 + + + + CertificateUri + Contains the Uri of a certificate of the calibration target, in case the calibration target is certified and the information available. Otherwise, the Property should be omitted. + + i=68 + i=80 + ns=1;i=1019 + + + + Identification + Provides identification information. + + i=78 + ns=2;i=1005 + ns=1;i=1019 + ns=1;i=6080 + ns=1;i=6081 + ns=1;i=6076 + ns=1;i=6082 + ns=1;i=6075 + ns=1;i=6073 + ns=2;i=15048 + ns=2;i=15035 + ns=1;i=6069 + ns=1;i=6070 + ns=1;i=6071 + ns=1;i=6072 + ns=1;i=6078 + ns=1;i=6079 + ns=1;i=6077 + ns=1;i=6074 + + + + AssetId + + i=68 + i=80 + ns=1;i=5010 + + + + ComponentName + + i=68 + i=80 + ns=1;i=5010 + + + + DeviceClass + + i=68 + i=80 + ns=1;i=5010 + + + + DeviceManual + + i=68 + i=80 + ns=1;i=5010 + + + + DeviceRevision + + i=68 + i=80 + ns=1;i=5010 + + + + HardwareRevision + + i=68 + i=80 + ns=1;i=5010 + + + + Manufacturer + + i=68 + i=80 + ns=1;i=5010 + + + + ManufacturerUri + + i=68 + i=80 + ns=1;i=5010 + + + + Model + + i=68 + i=80 + ns=1;i=5010 + + + + ProductCode + + i=68 + i=80 + ns=1;i=5010 + + + + ProductInstanceUri + + i=68 + i=80 + ns=1;i=5010 + + + + RevisionCounter + + i=68 + i=80 + ns=1;i=5010 + + + + SerialNumber + + i=68 + i=80 + ns=1;i=5010 + + + + SoftwareRevision + + i=68 + i=80 + ns=1;i=5010 + + + + LastValidationDate + Provides the date, the calibration target was validated the last time. If there is no specific validation date known, the date when the calibration target was bought or created should be used. + + i=68 + i=80 + ns=1;i=1019 + + + + NextValidationDate + Provides the date, when the calibration target should be validated the next time. If this date is not known, the Property should be omitted. Note: Potentially the NextValidationDate is in the past, when the next validation did not take place. + + i=68 + i=80 + ns=1;i=1019 + + + + OperationalConditions + A folder containing information about operational conditions of the calibration target. For example, it might provide in what ranges of humidity the calibration target can be operated. It might also provide correction information, for example, depending on the temperature the calibration values need to be corrected (in case of a length, the length might increase with high temperatures). If no operational conditions are provided, this folder should be omitted. + + i=80 + i=61 + ns=1;i=1019 + + + + Quality + Provides the quality of the calibration target in percentage, this is, the value shall be between 0 and 100. 100 means the highest quality, 0 the lowest. The semantic of the quality is application-specific. + + i=68 + i=80 + ns=1;i=1019 + + + + ControlChannelType + Used for control channels of single colour elements within a stack element (e.g. RGB elements would use three ControlChannels, one for each controllable colour). + https://reference.opcfoundation.org/IA/v101/docs/5.2.8 + + i=58 + ns=1;i=6024 + ns=1;i=6026 + ns=1;i=6025 + ns=1;i=6023 + + + + ChannelColor + Indicates in what mode (continuously on, blinking, flashing) the channel operates when switched on. + + i=78 + i=63 + ns=1;i=1008 + + + + Intensity + Shows the channelโ€™s intensity, thus its brightness. The mandatory EURange Property of the Variable indicates the lowest and highest value and thereby allows to calculate the percentage represented by the value. The lowest value is interpreted as 0 percent, the highest is interpreted as 100 percent. + + i=80 + i=2368 + ns=1;i=1008 + ns=1;i=6027 + + + + EURange + + i=68 + i=78 + ns=1;i=6026 + + + + SignalMode + Contains a list of audio signals used by this acoustic stacklight element. + + i=78 + i=63 + ns=1;i=1008 + + + + SignalOn + Indicates if the colour is switched on. + + i=68 + i=78 + ns=1;i=1008 + + + + BasicStacklightType + Entry point to a stacklight containing elements of the stacklight as well as additional information valid for the whole unit. + https://reference.opcfoundation.org/IA/v101/docs/5.2.1 + + i=23518 + ns=1;i=5006 + ns=1;i=5001 + ns=1;i=6009 + ns=1;i=5005 + + + + <OrderedObject> + Represent the stack elements (lamps and acoustic elements) the stacklight is composed of. The HasOrderedComponent Reference shall represent the ordering from the base of the stacklight. + + i=11508 + ns=1;i=1005 + ns=1;i=1002 + ns=1;i=6037 + + + + NumberInList + Enumerate the stacklight elements counting upwards beginning from the base of the stacklight. + + i=68 + i=78 + ns=1;i=5006 + + + + StackLevel + Valid if the stacklight is used in โ€œLevelmeterโ€ StacklightMode. If so, the whole stack is controlled by a single percentual value. In this case, the SignalOn parameter of any stack element of StackElementLightType has no meaning. + + i=80 + ns=1;i=1003 + ns=1;i=1002 + ns=1;i=6034 + ns=1;i=6035 + + + + DisplayMode + Indicates in what way the percentual value is displayed with the stacklight. + + i=78 + i=63 + ns=1;i=5001 + + + + LevelPercent + Shows the percentual value the stacklight is representing. The mandatory EURange Property of the Variable indicates the lowest and highest value and thereby allows to calculate the percentage represented by the value. The lowest value is interpreted as 0 percent, the highest is interpreted as 100 percent. + + i=78 + i=2368 + ns=1;i=5001 + ns=1;i=6036 + + + + EURange + + i=68 + i=78 + ns=1;i=6035 + + + + StacklightMode + Shows in what way (stack of individual lights, level meter, running light) the stacklight unit is used. + + i=68 + i=78 + ns=1;i=1002 + + + + StackRunning + Valid if the stacklight is used in โ€œRunning_Lightโ€ StacklightMode. + + i=80 + ns=1;i=1004 + ns=1;i=1002 + + + + StacklightType + Entry point to a stacklight with the possibility to show the stacklightโ€™s health status. + https://reference.opcfoundation.org/IA/v101/docs/5.2.2 + + ns=1;i=1002 + ns=1;i=6038 + ns=1;i=5007 + ns=2;i=15051 + + + + DeviceHealth + Contains the health status information of the stacklight. + + i=80 + i=63 + ns=1;i=1010 + + + + DeviceHealthAlarms + Contains alarms of the stacklights providing more detailed information on the health of the stacklight. + + i=80 + i=61 + ns=1;i=1010 + + + + StackElementType + Base class for elements in a stacklight. + https://reference.opcfoundation.org/IA/v101/docs/5.2.5 + + i=58 + i=23513 + ns=1;i=6014 + ns=1;i=6015 + ns=1;i=6013 + + + + IsPartOfBase + Indicates, if the element is contained in the mounting base of the stacklight. All elements contained in the mounting base shall be at the beginning of the list of stack elements. + + i=68 + i=80 + ns=1;i=1005 + + + + NumberInList + Enumerate the stacklight elements counting upwards beginning from the base of the stacklight. + + i=68 + i=78 + ns=1;i=1005 + + + + SignalOn + Indicates if the signal emitted by the stack element is currently switched on or not. + + i=68 + i=80 + ns=1;i=1005 + + + + StackElementAcousticType + Represents an acoustic element in a stacklight. + https://reference.opcfoundation.org/IA/v101/docs/5.2.7 + + ns=1;i=1005 + ns=1;i=5003 + ns=1;i=6021 + ns=1;i=6020 + + + + AcousticSignals + Contains a list of audio signals used by this acoustic stacklight element. + + i=23518 + i=78 + ns=1;i=1007 + ns=1;i=5004 + + + + <OrderedObject> + Represents an acoustic signal. + + i=11510 + ns=1;i=1009 + ns=1;i=5003 + ns=1;i=6030 + + + + NumberInList + Enumerate the acoustic signals. Instances of StackElementAcousticType index into this number using the OperationMode Property. + + i=68 + i=78 + ns=1;i=5004 + + + + Intensity + Indicates the sound pressure level of the acoustic signal when switched on. This value shall only have positive values. The mandatory EURange Property of the Variable indicates the lowest and highest value and thereby allows to calculate the percentage represented by the value. The lowest value is interpreted as 0 percent, the highest is interpreted as 100 percent. + + i=80 + i=2368 + ns=1;i=1007 + ns=1;i=6022 + + + + EURange + + i=68 + i=78 + ns=1;i=6021 + + + + OperationMode + Indicates what signal of the list of AcousticSignalType nodes is played when the acoustic element is switched on. It shall contain an index into the NumberInList of the respective AcousticSignalType Object of AcousticSignals. + + i=78 + i=63 + ns=1;i=1007 + + + + StackElementLightType + Represents a lamp element in a stacklight. + https://reference.opcfoundation.org/IA/v101/docs/5.2.6 + + ns=1;i=1005 + ns=1;i=5002 + ns=1;i=6018 + ns=1;i=6016 + ns=1;i=6017 + ns=1;i=6052 + + + + <ControlChannel> + The list of <ControlChannel> instances shows the control information for each independent colour channel of the stacked element. + + i=11508 + ns=1;i=1008 + ns=1;i=1006 + ns=1;i=6031 + ns=1;i=6032 + ns=1;i=6033 + + + + ChannelColor + Indicates in what mode (continuously on, blinking, flashing) the channel operates when switched on. + + i=78 + i=63 + ns=1;i=5002 + + + + SignalMode + Contains a list of audio signals used by this acoustic stacklight element. + + i=78 + i=63 + ns=1;i=5002 + + + + SignalOn + Indicates if the colour is switched on. + + i=68 + i=78 + ns=1;i=5002 + + + + Intensity + Intensity of the lamp, thus its brightness. The mandatory EURange Property of the Variable indicates the lowest and highest value and thereby allows to calculate the percentage represented by the value. The lowest value is interpreted as 0 percent, the highest is interpreted as 100 percent. + + i=80 + i=2368 + ns=1;i=1006 + ns=1;i=6019 + + + + EURange + + i=68 + i=78 + ns=1;i=6018 + + + + SignalColor + Indicates the colour the lamp element has when switched on. + + i=80 + i=63 + ns=1;i=1006 + + + + SignalMode + Shows in what way the lamp is used (continuous light, flashing, blinking) when switched on. + + i=80 + i=63 + ns=1;i=1006 + + + + SignalRGBWValue + + i=80 + i=63 + ns=1;i=1006 + + + + StackLevelType + Contains information relevant to a stacklight operating as a level meter. The whole stack is controlled by a percentual value. + https://reference.opcfoundation.org/IA/v101/docs/5.2.3 + + i=58 + ns=1;i=6012 + ns=1;i=6010 + + + + DisplayMode + Indicates in what way the percentual value is displayed with the stacklight. + + i=78 + i=63 + ns=1;i=1003 + + + + LevelPercent + Shows the percentual value the stacklight is representing. The mandatory EURange Property of the Variable indicates the lowest and highest value and thereby allows to calculate the percentage represented by the value. The lowest value is interpreted as 0 percent, the highest is interpreted as 100 percent. + + i=78 + i=2368 + ns=1;i=1003 + ns=1;i=6011 + + + + EURange + + i=68 + i=78 + ns=1;i=6010 + + + + StackRunningType + Contains information relevant to a stacklight operating as a running light. This base type does not define any specific information, but can be extended. + https://reference.opcfoundation.org/IA/v101/docs/5.2.4 + + i=58 + + + + http://opcfoundation.org/UA/IA/ + https://reference.opcfoundation.org/IA/v101/docs/9.1 + + i=11616 + ns=1;i=6039 + ns=1;i=6040 + ns=1;i=6041 + ns=1;i=6042 + ns=1;i=6043 + ns=1;i=6044 + ns=1;i=6045 + i=11715 + + + + IsNamespaceSubset + + i=68 + ns=1;i=5008 + + + false + + + + NamespacePublicationDate + + i=68 + ns=1;i=5008 + + + 2025-05-23T00:00:00Z + + + + NamespaceUri + + i=68 + ns=1;i=5008 + + + http://opcfoundation.org/UA/IA/ + + + + NamespaceVersion + + i=68 + ns=1;i=5008 + + + 1.01.4 + + + + StaticNodeIdTypes + + i=68 + ns=1;i=5008 + + + + 0 + + + + + StaticNumericNodeIdRange + + i=68 + ns=1;i=5008 + + + + StaticStringNodeIdPattern + + i=68 + ns=1;i=5008 + + + + Default Binary + + i=76 + ns=1;i=3007 + ns=1;i=6050 + + + + Default XML + + i=76 + ns=1;i=3007 + ns=1;i=6051 + + + diff --git a/src/Opc.Ua.Robotics/Model/Opc.Ua.Robotics.NodeSet2.xml b/src/Opc.Ua.Robotics/Model/Opc.Ua.Robotics.NodeSet2.xml new file mode 100644 index 0000000000..e97e9b88f3 --- /dev/null +++ b/src/Opc.Ua.Robotics/Model/Opc.Ua.Robotics.NodeSet2.xml @@ -0,0 +1,7409 @@ + + + + + + http://opcfoundation.org/UA/DI/ + http://opcfoundation.org/UA/IA/ + http://opcfoundation.org/UA/Robotics/ + + + + + + + + + + i=1 + i=4 + i=5 + i=6 + i=7 + i=11 + i=13 + i=12 + i=15 + i=17 + i=20 + i=21 + i=19 + i=47 + i=46 + i=35 + i=45 + i=40 + i=37 + i=53 + i=52 + i=51 + i=54 + i=9004 + i=17604 + i=17603 + i=256 + i=291 + i=887 + i=294 + i=7594 + i=41 + i=95 + i=96 + i=117 + i=296 + i=12879 + i=18806 + i=18808 + i=18810 + i=18812 + i=18814 + ns=3;i=3006 + ns=3;i=3008 + ns=3;i=18178 + ns=3;i=18180 + ns=3;i=18182 + ns=3;i=18183 + ns=3;i=18191 + ns=3;i=18193 + + + + + + + + + + + AxisMotionProfileEnumeration + https://reference.opcfoundation.org/Robotics/v100/docs/10.2 + + i=29 + + + + Any motion-profile which is not defined by the AxisMotionProfileEnumeration. + + + Rotary motion is a rotation along a circular path with defined limits. Motion movement is not going always in the same direction. Control unit is mainly degree. + + + Rotary motion is a rotation along a circular path with no limits. Motion movement is going endless in the same direction. Control unit is mainly degree. + + + Linear motion is a one dimensional motion along a straight line with defined limits. Motion movement is not going always in the same direction. Control unit is mainly mm. + + + Linear motion is a one dimensional motion along a straight line with no limits. Motion movement is going endless in the same direction. Control unit is mainly mm. + + + + + EnumStrings + + ns=3;i=3008 + i=68 + + + + + OTHER + + + ROTARY + + + ROTARY_ENDLESS + + + LINEAR + + + LINEAR_ENDLESS + + + + + + ExecutionModeEnumeration + https://reference.opcfoundation.org/Robotics/v100/docs/10.3 + + i=29 + + + + Single execution of a task program according to ISO 8373. + + + Task program is executed continuously and starts again automatically. + + + Task program is executed in steps. + + + + + EnumStrings + + ns=3;i=18191 + i=68 + + + + + CYCLE + + + CONTINUOUS + + + STEP + + + + + + MotionDeviceCategoryEnumeration + https://reference.opcfoundation.org/Robotics/v100/docs/10.1 + + i=29 + + + + Any MotionDevice which is not defined by the MotionDeviceCategoryEnumeration. + + + This robot design features rotary joints and can range from simple two joint structures to 10 or more joints. The arm is connected to the base with a twisting joint. The links in the arm are connected by rotary joints. + + + The robot has two parallel rotary joints that ensure compliance in a specified plane. + + + Cartesian robots have three linear joints that use the Cartesian coordinate system (X, Y, and Z). They also may have an attached wrist to allow for rotational movement. The three prismatic joints provide linear movement along the axis. + + + The arm is connected to the base with a twisting joint and a combination of two rotary joints and one linear joint. The axes form a polar coordinate system and create a spherical shaped work envelope. + + + These spider like robots are built from jointed parallelograms connected to a common base. The parallelograms move a single EOAT in a dome-shaped work area. + + + The robot has at least one rotary joint at the base and at least one prismatic joint to connect the links. The rotary joint uses a rotational motion along the joint axis, while the prismatic joint moves in a linear motion. Cylindrical robots operate within a cylindrical-shaped work envelope. + + + + + EnumStrings + + ns=3;i=18193 + i=68 + + + + + OTHER + + + ARTICULATED_ROBOT + + + SCARA_ROBOT + + + CARTESIAN_ROBOT + + + SPHERICAL_ROBOT + + + PARALLEL_ROBOT + + + CYLINDRICAL_ROBOT + + + + + + OperationalModeEnumeration + https://reference.opcfoundation.org/Robotics/v100/docs/10.4 + + i=29 + + + + Other for when there is a system-boot (unknown) or a failure of the safety, there is no valid operational mode. + + + "Manual reduced speed" - name according to ISO 10218-1:2011. + + + "Manual high speed" - name according to ISO 10218-1:2011. + + + "Automatic" - name according to ISO 10218-1:2011. + + + "Automatic external" - Same as "Automatic" but with external control, e.g. by a PLC. + + + + + EnumStrings + + ns=3;i=3006 + i=68 + + + + + OTHER + + + MANUAL_REDUCED_SPEED + + + MANUAL_HIGH_SPEED + + + AUTOMATIC + + + AUTOMATIC_EXTERNAL + + + + + + Controls + Reference: Describe dependencies between objects which have a controlling character. + https://reference.opcfoundation.org/Robotics/v100/docs/8.2 + + i=33 + + IsControlledBy + + + HasSafetyStates + Reference: Describe dependencies between objects to show which (controller) object is responsible for the execution of the safety-functionality. The BrowseName HasSafetyStates and the InverseName SafetyStatesOf describe semantically the hierarchical dependency. + https://reference.opcfoundation.org/Robotics/v100/docs/8.7 + + i=33 + + SafetyStatesOf + + + HasSlave + Reference: Provide the master-slave relationship of powertrains which provide torque for a common axis. The InverseName is IsSlaveOf. + https://reference.opcfoundation.org/Robotics/v100/docs/8.8 + + i=33 + + IsSlaveOf + + + IsDrivenBy + Reference: Describe dependencies between objects which have a driving or powering character. The BrowseName IsDrivenBy and the InverseName Drives describe semantically the hierarchical dependency. + https://reference.opcfoundation.org/Robotics/v100/docs/8.5 + + i=33 + + Drives + + + Moves + Reference: Describe the coupling between a powertrain and the axes from the powertrain point of view. + https://reference.opcfoundation.org/Robotics/v100/docs/8.3 + + i=33 + + IsMovedBy + + + Requires + Reference: Describe the coupling between a powertrain and axes from the axis point of view. An axis has a Requires reference to all powertrains that need to move such that only this single axis moves. + https://reference.opcfoundation.org/Robotics/v100/docs/8.4 + + i=33 + + IsRequiredBy + + + IsConnectedTo + Reference: Describe dependencies between objects which are mounted or mechanically linked or connected to each other. The IsConnectedTo reference is symmetric and has no InverseName. + https://reference.opcfoundation.org/Robotics/v100/docs/8.6 + + i=32 + + IsConnectedTo + + + MultiAcknowledgeableConditionType + Rob RobAckCondInstance + https://reference.opcfoundation.org/Robotics/v100/docs/9.1 + + i=2881 + ns=3;i=6140 + + + + ConditionDescriptions + + i=78 + i=68 + + + + EmergencyStopFunctionType + According to ISO 10218-1:2011 Ch.5.5.2 Emergency stop the robot shall have one or more emergency stop functions. + Rob Emergency Stop Function + https://reference.opcfoundation.org/Robotics/v100/docs/7.8.2 + + i=58 + ns=3;i=17232 + ns=3;i=17231 + + + + Active + The Active variable is TRUE if this particular emergency stop function is active, e.g. that the emergency stop button is pressed, FALSE otherwise. + + i=63 + i=78 + + + + Name + The Name of the EmergencyStopFunctionType provides a manufacturer-specific emergency stop function identifier within the safety system. + + i=68 + i=78 + + + + LoadType + The LoadType is for describing loads mounted on the motion device typically by an integrator or a customer. + https://reference.opcfoundation.org/Robotics/v100/docs/7.23.2 + + i=58 + ns=3;i=6013 + ns=3;i=18170 + ns=3;i=6723 + + + + CenterOfMass + The position and orientation of the center of the mass related to the mounting point using a FrameType. X, Y, Z define the position of the center of gravity relative to the mounting point coordinate system. A, B, C define the orientation of the principal axes of inertia relative to the mounting point coordinate system. Orientation A, B, C can be "0" for systems which do not need these values. + + i=18791 + i=80 + ns=3;i=16130 + ns=3;i=16132 + + + + CartesianCoordinates + + i=18774 + i=78 + ns=3;i=16134 + ns=3;i=16135 + ns=3;i=16136 + + + + X + + i=63 + i=78 + + + + Y + + i=63 + i=78 + + + + Z + + i=63 + i=78 + + + + Orientation + + i=18781 + i=78 + ns=3;i=16137 + ns=3;i=16138 + ns=3;i=16139 + + + + A + + i=63 + i=78 + + + + B + + i=63 + i=78 + + + + C + + i=63 + i=78 + + + + Inertia + The Inertia uses the VectorType to describe the three values of the principal moments of inertia with respect to the mounting point coordinate system. If inertia values are provided for rotary axis the CenterOfMass shall be completely filled as well. + + i=17716 + i=80 + ns=3;i=18171 + ns=3;i=18172 + ns=3;i=18173 + + + + X + + i=63 + i=78 + + + + Y + + i=63 + i=78 + + + + Z + + i=63 + i=78 + + + + Mass + The weight of the load mounted on one mounting point. + + i=17497 + i=78 + ns=3;i=6728 + + + + EngineeringUnits + + i=68 + i=78 + + + + ProtectiveStopFunctionType + According to ISO 10218-1:2011 Ch.5.5.3 the robot shall have one or more protective stop functions designed for the connection of external protective devices. + Rob Protective Stop Function + https://reference.opcfoundation.org/Robotics/v100/docs/7.9.2 + + i=58 + ns=3;i=17236 + ns=3;i=17235 + ns=3;i=17234 + + + + Active + โ€“ The Active variable is TRUE if this particular protective stop function is active, i.e. that a stop is initiated, FALSE otherwise. If Enabled is FALSE then Active shall be FALSE. + + i=63 + i=78 + + + + Enabled + โ€“ The Enabled variable is TRUE if this protective stop function is currently supervising the system, FALSE otherwise. A protective stop function may or may not be enabled at all times, e.g. the protective stop function of the safety doors are typically enabled in automatic operational mode and disabled in manual mode. On the other hand for example, the protective stop function of the teach pendant enabling device is enabled in manual modes and disabled in automatic modes. + + i=63 + i=78 + + + + Name + The Name of the ProtectiveStopFunctionType provides a manufacturer-specific protective stop function identifier within the safety system. + + i=68 + i=78 + + + + ExecutingSubstateMachineType + Rob System ExecutingSubstate + Rob System Events + https://reference.opcfoundation.org/Robotics/v100/docs/7.14.1 + + i=2771 + i=2311 + ns=3;i=6036 + ns=3;i=6038 + ns=3;i=5020 + ns=3;i=5022 + ns=3;i=5021 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6037 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6039 + ns=3;i=6040 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + Running + + i=2309 + ns=3;i=6041 + + + + StateNumber + + i=78 + i=68 + + + 1 + + + + RunningToStopping + + i=2310 + ns=3;i=5020 + ns=3;i=5021 + i=2311 + ns=3;i=6043 + + + + TransitionNumber + + i=78 + i=68 + + + 1 + + + + Stopping + + i=2307 + ns=3;i=6042 + + + + StateNumber + + i=78 + i=68 + + + 2 + + + + IdleSubstateMachineType + Rob System IdleSubstate + Rob System Events + https://reference.opcfoundation.org/Robotics/v100/docs/7.13.1 + + i=2771 + i=2311 + ns=3;i=5017 + ns=3;i=5018 + ns=3;i=6026 + ns=3;i=6029 + ns=3;i=5015 + ns=3;i=5019 + + + + GettingReady + + i=2307 + ns=3;i=6031 + + + + StateNumber + + i=78 + i=68 + + + 2 + + + + GettingReadyToStandBy + + i=2310 + ns=3;i=5017 + ns=3;i=5015 + i=2311 + ns=3;i=6032 + + + + TransitionNumber + + i=78 + i=68 + + + 2 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6028 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6034 + ns=3;i=6035 + + + + EnumValues + + i=68 + i=78 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + StandBy + + i=2309 + ns=3;i=6030 + + + + StateNumber + + i=78 + i=68 + + + 1 + + + + StandByToGettingReady + + i=2310 + ns=3;i=5017 + ns=3;i=5015 + i=2311 + ns=3;i=6033 + + + + TransitionNumber + + i=78 + i=68 + + + 1 + + + + OperationStateMachineType + https://reference.opcfoundation.org/Robotics/v100/docs/7.10.1 + + i=2771 + i=2311 + ns=3;i=6009 + ns=3;i=5007 + ns=3;i=5013 + ns=3;i=5011 + ns=3;i=5005 + ns=3;i=5014 + ns=3;i=5009 + ns=3;i=6005 + ns=3;i=6007 + ns=3;i=6008 + ns=3;i=5006 + ns=3;i=5012 + ns=3;i=5008 + ns=3;i=7001 + ns=3;i=7002 + + + + ConfiguredDefaultStopMode + + i=80 + i=63 + + + + Executing + Entity is in a condition of execution. + + i=2307 + ns=3;i=6012 + + + + StateNumber + + i=78 + i=68 + + + 3 + + + + ExecutingToIdle + Changes from Executing to Idle + + i=2310 + ns=3;i=5007 + ns=3;i=5005 + i=2311 + ns=3;i=6018 + + + + TransitionNumber + + i=78 + i=68 + + + 6 + + + + ExecutingToReady + Changes from Executing to Ready + + i=2310 + ns=3;i=5007 + ns=3;i=5006 + ns=3;i=7002 + i=2311 + ns=3;i=6016 + + + + TransitionNumber + + i=78 + i=68 + + + 5 + + + + Idle + Entity is not in a condition to start execution. + + i=2307 + ns=3;i=6010 + + + + StateNumber + + i=78 + i=68 + + + 1 + + + + IdleToIdle + Changes from Idle to Idle. + + i=2310 + ns=3;i=5005 + ns=3;i=5005 + i=2311 + ns=3;i=6019 + + + + TransitionNumber + + i=78 + i=68 + + + 1 + + + + IdleToReady + Changes from Idle to Ready + + i=2310 + ns=3;i=5005 + ns=3;i=5006 + i=2311 + ns=3;i=6015 + + + + TransitionNumber + + i=78 + i=68 + + + 2 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6006 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6020 + ns=3;i=6021 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + PossibleStopModes + + i=80 + i=63 + + + + + + i=7616 + + + + 1 + + en + OnPath + + + en + Stop program execution in a controlled manner along the programmed path + + + + + + + i=7616 + + + + 2 + + en + EndOfCycle + + + en + Stop program execution when the current production cycle has been finished + + + + + + + i=7616 + + + + 3 + + en + ProcessStop + + + en + Application dependent stop instruction that stops program execution at a favourable point for the application, e.g. at the end of a paint stroke or sealing bead + + + + + + + i=7616 + + + + 4 + + en + QuickStop + + + en + This stop is performed by ramping down motion as fast as possible using optimum motor performance. The robot may not stay on the path + + + + + + + i=7616 + + + + 5 + + en + EndOfInstruction + + + en + This stop can be used to stop the program execution when the current instruction is completed + + + + + + + + + Ready + Entity is in a condition to start execution. + + i=2307 + ns=3;i=6011 + + + + StateNumber + + i=78 + i=68 + + + 2 + + + + ReadyToExecuting + Changes from Ready to Executing + + i=2310 + ns=3;i=5007 + ns=3;i=5006 + ns=3;i=7001 + i=2311 + ns=3;i=6017 + + + + TransitionNumber + + i=78 + i=68 + + + 4 + + + + ReadyToIdle + Changes from Ready to Idle + + i=2310 + ns=3;i=5005 + ns=3;i=5006 + i=2311 + ns=3;i=6014 + + + + TransitionNumber + + i=78 + i=68 + + + 3 + + + + Start + https://reference.opcfoundation.org/Robotics/v100/docs/7.10.1 + + i=80 + ns=3;i=6023 + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK +Values > 0 are reserved for errors defined by this and future standards. +Values < 0 shall be used for application-specific errors + + + + + + + + + Stop + https://reference.opcfoundation.org/Robotics/v100/docs/7.10.2 + + i=80 + ns=3;i=6024 + ns=3;i=6025 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + StopMode + + i=8 + + -1 + + + provides a way to differentiate between different stop modes. This parameter should correspond to one of the values in the PossibleStopModes array + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK +Values > 0 are reserved for errors defined by this and future standards. +Values < 0 shall be used for application-specific errors + + + + + + + + + SystemOperationStateMachineType + Rob System Monitor + Rob System Operation + Rob System Events + Rob System Idle Substate + Rob System ExecutingSubstate + https://reference.opcfoundation.org/Robotics/v100/docs/7.12.1 + + ns=3;i=1006 + i=2311 + ns=3;i=6057 + ns=3;i=5032 + ns=3;i=5028 + ns=3;i=5038 + ns=3;i=5037 + ns=3;i=7006 + ns=3;i=5030 + ns=3;i=5027 + ns=3;i=5033 + ns=3;i=5034 + ns=3;i=6058 + ns=3;i=6060 + ns=3;i=6063 + ns=3;i=5031 + ns=3;i=5036 + ns=3;i=5035 + ns=3;i=7007 + ns=3;i=7004 + ns=3;i=7005 + + + + ConfiguredDefaultStopMode + + i=80 + i=63 + + + + Executing + + i=2307 + ns=3;i=5028 + ns=3;i=6087 + + + + StateNumber + + i=78 + i=68 + + + 3 + + + + ExecutingSubstateMachine + + i=80 + ns=3;i=1007 + ns=3;i=6083 + ns=3;i=6078 + ns=3;i=6080 + + + + CurrentState + + i=78 + i=2760 + ns=3;i=6084 + + + + Id + + i=78 + i=68 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6079 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6081 + ns=3;i=6082 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + ExecutingToIdle + + i=2310 + ns=3;i=5032 + ns=3;i=5030 + i=2311 + ns=3;i=6093 + + + + TransitionNumber + + i=78 + i=68 + + + 6 + + + + ExecutingToReady + + i=2310 + ns=3;i=5032 + ns=3;i=5031 + ns=3;i=7005 + i=2311 + ns=3;i=6092 + + + + TransitionNumber + + i=78 + i=68 + + + 5 + + + + GetReady + Rob System Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.12.3 + + i=80 + ns=3;i=6068 + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK +Values > 0 are reserved for errors defined by this and future standards. +Values < 0 shall be used for application-specific errors + + + + + + + + + Idle + + i=2307 + ns=3;i=5027 + ns=3;i=6085 + + + + StateNumber + + i=78 + i=68 + + + 1 + + + + IdleSubstateMachine + + i=80 + ns=3;i=1009 + ns=3;i=6076 + ns=3;i=6071 + ns=3;i=6073 + + + + CurrentState + + i=78 + i=2760 + ns=3;i=6077 + + + + Id + + i=78 + i=68 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6072 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6074 + ns=3;i=6075 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + IdleToIdle + + i=2310 + ns=3;i=5030 + ns=3;i=5030 + ns=3;i=7007 + i=2311 + ns=3;i=6088 + + + + TransitionNumber + + i=78 + i=68 + + + 1 + + + + IdleToReady + + i=2310 + ns=3;i=7006 + ns=3;i=5030 + ns=3;i=5031 + i=2311 + ns=3;i=6089 + + + + TransitionNumber + + i=78 + i=68 + + + 2 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6059 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6061 + ns=3;i=6062 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + PossibleStopModes + + i=80 + i=63 + + + + + + i=7616 + + + + 1 + + en + OnPath + + + en + Stop program execution in a controlled manner along the programmed path + + + + + + + i=7616 + + + + 2 + + en + EndOfCycle + + + en + Stop program execution when the current production cycle has been finished + + + + + + + i=7616 + + + + 3 + + en + ProcessStop + + + en + Application dependent stop instruction that stops program execution at a favourable point for the application, e.g. at the end of a paint stroke or sealing bead + + + + + + + i=7616 + + + + 4 + + en + QuickStop + + + en + This stop is performed by ramping down motion as fast as possible using optimum motor performance. The robot may not stay on the path + + + + + + + i=7616 + + + + 5 + + en + EndOfInstruction + + + en + This stop can be used to stop the program execution when the current instruction is completed + + + + + + + + + Ready + + i=2307 + ns=3;i=6086 + + + + StateNumber + + i=78 + i=68 + + + 2 + + + + ReadyToExecuting + + i=2310 + ns=3;i=5032 + ns=3;i=5031 + ns=3;i=7004 + i=2311 + ns=3;i=6091 + + + + TransitionNumber + + i=78 + i=68 + + + 4 + + + + ReadyToIdle + + i=2310 + ns=3;i=5030 + ns=3;i=5031 + ns=3;i=7007 + i=2311 + ns=3;i=6090 + + + + TransitionNumber + + i=78 + i=68 + + + 3 + + + + StandDown + Rob System Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.12.4 + + i=80 + ns=3;i=6070 + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK +Values > 0 are reserved for errors defined by this and future standards. +Values < 0 shall be used for application-specific errors + + + + + + + + + Start + Rob System Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.12.1 + + i=80 + ns=3;i=6064 + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK +Values > 0 are reserved for errors defined by this and future standards. +Values < 0 shall be used for application-specific errors + + + + + + + + + Stop + Rob System Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.12.2 + + i=80 + ns=3;i=6065 + ns=3;i=6066 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + StopMode + + i=8 + + -1 + + + provides a way to differentiate between different stop modes. This parameter should correspond to one of the values in the PossibleStopModes array + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK +Values > 0 are reserved for errors defined by this and future standards. +Values < 0 shall be used for application-specific errors + + + + + + + + + TaskControlStateMachineType + Rob Task Control Monitor + Rob Task Control Operation + Rob Task Control ReadySubstate + Rob System Events + https://reference.opcfoundation.org/Robotics/v100/docs/7.16.1 + + ns=3;i=1006 + i=2311 + ns=3;i=6094 + ns=3;i=5042 + ns=3;i=5048 + ns=3;i=5047 + ns=3;i=5040 + ns=3;i=5043 + ns=3;i=5044 + ns=3;i=6095 + ns=3;i=6097 + ns=3;i=7011 + ns=3;i=7010 + ns=3;i=6100 + ns=3;i=5041 + ns=3;i=5039 + ns=3;i=5046 + ns=3;i=5045 + ns=3;i=7008 + ns=3;i=7009 + ns=3;i=7014 + ns=3;i=7013 + ns=3;i=7012 + + + + ConfiguredDefaultStopMode + + i=80 + i=63 + + + + Executing + + i=2307 + ns=3;i=6114 + + + + StateNumber + + i=78 + i=68 + + + 3 + + + + ExecutingToIdle + + i=2310 + ns=3;i=5042 + ns=3;i=5040 + i=2311 + ns=3;i=6122 + + + + TransitionNumber + + i=78 + i=68 + + + 6 + + + + ExecutingToReady + + i=2310 + ns=3;i=5042 + ns=3;i=5041 + ns=3;i=7009 + i=2311 + ns=3;i=6121 + + + + TransitionNumber + + i=78 + i=68 + + + 5 + + + + Idle + + i=2307 + ns=3;i=6112 + + + + StateNumber + + i=78 + i=68 + + + 1 + + + + IdleToIdle + + i=2310 + ns=3;i=5040 + ns=3;i=5040 + i=2311 + ns=3;i=6115 + + + + TransitionNumber + + i=78 + i=68 + + + 1 + + + + IdleToReady + + i=2310 + ns=3;i=5040 + ns=3;i=7011 + ns=3;i=7010 + ns=3;i=5041 + i=2311 + ns=3;i=6116 + + + + TransitionNumber + + i=78 + i=68 + + + 2 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6096 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6098 + ns=3;i=6099 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + LoadByName + Rob Task Control Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.16.3 + + i=80 + ns=3;i=6143 + ns=3;i=6144 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Name + + i=12 + + -1 + + + Name to identify a task control program or module + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK โ€“ Everything is OK +1 โ€“ E_SystemState โ€“ The system is not in correct state for this operation +2 โ€“ E_UnexpectedError โ€“ Unexpected Error during the method call +3 โ€“ E_ActiveAlarm โ€“ An Active Alarm prevents the system start +4 โ€“ E_AcknowledgeRequired โ€“ Condition needs to be acknowledged +<0 โ€“ shall be used for vendor-specific errors +>0 โ€“ are reserved for errors defined by this and future standards + + + + + + + + + LoadByNodeId + Rob Task Control Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.16.2 + + i=80 + ns=3;i=6141 + ns=3;i=6142 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Id + + i=18 + + -1 + + + ExpandedNodeId pointing to an instance of FileType representing a task control program or module + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK โ€“ Everything is OK +1 โ€“ E_SystemState โ€“ The system is not in correct state for this operation +2 โ€“ E_UnexpectedError โ€“ Unexpected Error during the method call +3 โ€“ E_ActiveAlarm โ€“ An Active Alarm prevents the system start +4 โ€“ E_AcknowledgeRequired โ€“ Condition needs to be acknowledged +<0 โ€“ shall be used for vendor-specific errors +>0 โ€“ are reserved for errors defined by this and future standards + + + + + + + + + PossibleStopModes + + i=80 + i=63 + + + + + + i=7616 + + + + 1 + + en + OnPath + + + en + Stop program execution in a controlled manner along the programmed path + + + + + + + i=7616 + + + + 2 + + en + EndOfCycle + + + en + Stop program execution when the current production cycle has been finished + + + + + + + i=7616 + + + + 3 + + en + ProcessStop + + + en + Application dependent stop instruction that stops program execution at a favourable point for the application, e.g. at the end of a paint stroke or sealing bead + + + + + + + i=7616 + + + + 4 + + en + QuickStop + + + en + This stop is performed by ramping down motion as fast as possible using optimum motor performance. The robot may not stay on the path + + + + + + + i=7616 + + + + 5 + + en + EndOfInstruction + + + en + This stop can be used to stop the program execution when the current instruction is completed + + + + + + + + + Ready + + i=2307 + ns=3;i=5039 + ns=3;i=6113 + + + + StateNumber + + i=78 + i=68 + + + 2 + + + + ReadySubstateMachine + + i=80 + ns=3;i=1012 + ns=3;i=6109 + ns=3;i=6104 + ns=3;i=6106 + + + + CurrentState + + i=78 + i=2760 + ns=3;i=6111 + + + + Id + + i=78 + i=68 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6105 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6107 + ns=3;i=6108 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + ReadyToExecuting + + i=2310 + ns=3;i=5042 + ns=3;i=5041 + ns=3;i=7008 + i=2311 + ns=3;i=6120 + + + + TransitionNumber + + i=78 + i=68 + + + 4 + + + + ReadyToIdle + + i=2310 + ns=3;i=5040 + ns=3;i=5041 + i=2311 + ns=3;i=7014 + ns=3;i=7013 + ns=3;i=7012 + ns=3;i=6119 + + + + TransitionNumber + + i=78 + i=68 + + + 3 + + + + Start + Rob Task Control Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.16.7 + + i=80 + ns=3;i=6101 + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK +Values > 0 are reserved for errors defined by this and future standards. +Values < 0 shall be used for application-specific errors + + + + + + + + + Stop + Rob Task Control Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.16.8 + + i=80 + ns=3;i=6102 + ns=3;i=6103 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + StopMode + + i=8 + + -1 + + + provides a way to differentiate between different stop modes. This parameter should correspond to one of the values in the PossibleStopModes array + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK +Values > 0 are reserved for errors defined by this and future standards. +Values < 0 shall be used for application-specific errors + + + + + + + + + UnloadByName + Rob Task Control Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.16.6 + + i=80 + ns=3;i=6067 + ns=3;i=6069 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Name + + i=12 + + -1 + + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + + + + + + + + UnloadByNodeId + Rob Task Control Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.16.5 + + i=80 + ns=3;i=6146 + ns=3;i=6147 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Id + + i=18 + + -1 + + + Expanded NodeId of the module to be unloaded + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK โ€“ Everything is OK +1 โ€“ E_SystemState โ€“ The system is not in correct state for this operation +2 โ€“ E_UnexpectedError โ€“ Unexpected Error during the method call +3 โ€“ E_ActiveAlarm โ€“ An Active Alarm prevents the system start +4 โ€“ E_AcknowledgeRequired โ€“ Condition needs to be acknowledged +<0 โ€“ shall be used for vendor-specific errors +>0 โ€“ are reserved for errors defined by this and future standards + + + + + + + + + UnloadProgram + Rob Task Control Operation + https://reference.opcfoundation.org/Robotics/v100/docs/7.16.4 + + i=80 + ns=3;i=6145 + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK โ€“ Everything is OK +1 โ€“ E_SystemState โ€“ The system is not in correct state for this operation +2 โ€“ E_UnexpectedError โ€“ Unexpected Error during the method call +3 โ€“ E_ActiveAlarm โ€“ An Active Alarm prevents the system start +4 โ€“ E_AcknowledgeRequired โ€“ Condition needs to be acknowledged +<0 โ€“ shall be used for vendor-specific errors +>0 โ€“ are reserved for errors defined by this and future standards + + + + + + + + + ReadySubstateMachineType + Rob Task Control ReadySubstate + Rob Task Control Ready Reset + https://reference.opcfoundation.org/Robotics/v100/docs/7.17.1 + + i=2771 + i=2311 + ns=3;i=5023 + ns=3;i=6044 + ns=3;i=6046 + ns=3;i=5025 + ns=3;i=7003 + ns=3;i=5024 + ns=3;i=5026 + + + + AtProgramStart + + i=2307 + ns=3;i=6049 + + + + StateNumber + + i=78 + i=68 + + + 1 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6045 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6047 + ns=3;i=6048 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + ProgramStartToSuspended + + i=2310 + ns=3;i=5023 + ns=3;i=5024 + i=2311 + ns=3;i=6051 + + + + TransitionNumber + + i=78 + i=68 + + + 1 + + + + ResetToProgramStart + Task Control Ready Reset + https://reference.opcfoundation.org/Robotics/v100/docs/7.17.2 + + i=80 + ns=3;i=6053 + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=6 + + -1 + + + 0 โ€“ OK โ€“ Everything is OK +1 โ€“ E_SystemState โ€“ The system is not in correct state for this operation +2 โ€“ E_UnexpectedError โ€“ Unexpected Error during the method call +3 โ€“ E_ActiveAlarm โ€“ An Active Alarm prevents the system start +4 โ€“ E_AcknowledgeRequired โ€“ Condition needs to be acknowledged +<0 โ€“ shall be used for vendor-specific errors +>0 โ€“ are reserved for errors defined by this and future standards + + + + + + + + + Suspended + + i=2307 + ns=3;i=6050 + + + + StateNumber + + i=78 + i=68 + + + 2 + + + + SuspendedToProgramStart + + i=2310 + ns=3;i=5023 + ns=3;i=7003 + ns=3;i=5024 + i=2311 + ns=3;i=6052 + + + + TransitionNumber + + i=78 + i=68 + + + 2 + + + + SystemOperationType + Rob System Monitor + Rob System Operation + Rob RobAckCondInstance + https://reference.opcfoundation.org/Robotics/v100/docs/7.11.2 + + i=58 + ns=3;i=5050 + ns=3;i=6130 + ns=3;i=5049 + + + + Conditions + + i=80 + i=61 + ns=3;i=5059 + + + + <AcknowledgeableCondition> + + i=11510 + i=2881 + ns=3;i=6185 + ns=3;i=7019 + ns=3;i=7020 + ns=3;i=6191 + ns=3;i=6192 + ns=3;i=6193 + ns=3;i=6195 + ns=3;i=6196 + ns=3;i=6197 + ns=3;i=6198 + ns=3;i=6199 + ns=3;i=7021 + ns=3;i=7022 + ns=3;i=6188 + ns=3;i=6205 + ns=3;i=6206 + ns=3;i=6200 + ns=3;i=6207 + ns=3;i=6202 + ns=3;i=6208 + ns=3;i=6204 + ns=3;i=6209 + ns=3;i=6210 + ns=3;i=6211 + ns=3;i=6212 + + + + AckedState + + i=78 + i=8995 + ns=3;i=6186 + + + + Id + + i=78 + i=68 + + + + Acknowledge + + i=78 + ns=3;i=6187 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + EventId + + i=15 + + -1 + + + The identifier for the event to comment. + + + + + + + i=297 + + + + Comment + + i=21 + + -1 + + + The comment to add to the condition. + + + + + + + + + AddComment + + i=78 + ns=3;i=6190 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + EventId + + i=15 + + -1 + + + The identifier for the event to comment. + + + + + + + i=297 + + + + Comment + + i=21 + + -1 + + + The comment to add to the condition. + + + + + + + + + BranchId + + i=78 + i=68 + + + + ClientUserId + + i=78 + i=68 + + + + Comment + + i=78 + i=9002 + ns=3;i=6194 + + + + SourceTimestamp + + i=78 + i=68 + + + + ConditionClassId + + i=78 + i=68 + + + + ConditionClassName + + i=78 + i=68 + + + + ConditionName + + i=78 + i=68 + + + + ConditionSubClassId + + i=78 + i=68 + + + + ConditionSubClassName + + i=78 + i=68 + + + + Disable + + i=78 + + + + Enable + + i=78 + + + + EnabledState + + i=78 + i=8995 + ns=3;i=6185 + ns=3;i=6189 + + + + Id + + i=78 + i=68 + + + + EventId + + i=78 + i=68 + + + + EventType + + i=78 + i=68 + + + + LastSeverity + + i=78 + i=9002 + ns=3;i=6201 + + + + SourceTimestamp + + i=78 + i=68 + + + + Message + + i=78 + i=68 + + + + Quality + + i=78 + i=9002 + ns=3;i=6203 + + + + SourceTimestamp + + i=78 + i=68 + + + + ReceiveTime + + i=78 + i=68 + + + + Retain + + i=78 + i=68 + + + + Severity + + i=78 + i=68 + + + + SourceName + + i=78 + i=68 + + + + SourceNode + + i=78 + i=68 + + + + Time + + i=78 + i=68 + + + + DefaultInstanceBrowseName + + i=68 + + + + 2 + SystemOperation + + + + + SystemOperationStateMachine + + i=78 + ns=3;i=1021 + ns=3;i=6128 + ns=3;i=6123 + ns=3;i=6125 + + + + CurrentState + + i=78 + i=2760 + ns=3;i=6129 + + + + Id + + i=78 + i=68 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6124 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6126 + ns=3;i=6127 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + TaskControlOperationType + Rob Task Control Monitor + Rob Task Control Operation + Rob TC MD Relationship + https://reference.opcfoundation.org/Robotics/v100/docs/7.15.1 + + i=58 + ns=3;i=6132 + ns=3;i=6131 + ns=3;i=5051 + + + + DefaultInstanceBrowseName + + i=68 + + + + 3 + TaskControlOperation + + + + + MotionDevicesUnderControl + + i=80 + i=68 + + + + TaskControlStateMachine + + i=78 + ns=3;i=1025 + ns=3;i=6138 + ns=3;i=6133 + ns=3;i=6135 + + + + CurrentState + + i=78 + i=2760 + ns=3;i=6139 + + + + Id + + i=78 + i=68 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6134 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6136 + ns=3;i=6137 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + TaskModuleType + Rob Task Control Modules + https://reference.opcfoundation.org/Robotics/v100/docs/7.22.2 + + i=58 + ns=3;i=6056 + ns=3;i=6054 + ns=3;i=6055 + + + + IsReferenced + + i=80 + i=68 + + + + Name + + i=78 + i=68 + + + + Version + + i=80 + i=68 + + + + AuxiliaryComponentType + Components mounted in a controller cabinet or a motion device e.g. an IO-board or a power supply. + https://reference.opcfoundation.org/Robotics/v100/docs/7.19.2 + + ns=1;i=15063 + ns=3;i=6183 + ns=3;i=17756 + + + + AssetId + + i=68 + i=80 + + + + ProductCode + The ProductCode property provides a unique combination of numbers and letters used to identify the product. It may be the order information displayed on type shields or in ERP systems. + + i=68 + i=78 + + + + AxisType + The AxisType describes an axis of a motion device. + Rob MotionDeviceSystem Base + Rob Axis AM Extended + Rob Axis CM Extended + Rob Axis AdditionalLoad + https://reference.opcfoundation.org/Robotics/v100/docs/7.3.2 + + ns=1;i=15063 + ns=3;i=18344 + ns=3;i=16638 + ns=3;i=6175 + ns=3;i=16637 + ns=3;i=16602 + + + + <PowerTrainIdentifier> + The Requires reference provides the relationship of axes to powertrains. For complex kinematics this does not need to be a one to one relationship, because more than one power train might influence the motion of one axis. This reference connects all power trains to an axis that must be actively driven when only this axis should move and all other axes should stand still. Virtual axes that are not actively driven by a power train do not have this reference. + + ns=3;i=16794 + i=11508 + ns=3;i=15998 + + + + AdditionalLoad + The additional load which is mounted on this axis. E.g. for process-need a transformer for welding. + + ns=3;i=1018 + i=80 + ns=3;i=16639 + + + + Mass + The weight of the load mounted on one mounting point. + + i=17497 + i=78 + ns=3;i=16644 + + + + EngineeringUnits + + i=68 + i=78 + + + + AssetId + + i=68 + i=80 + + + + MotionProfile + The kind of axis motion as defined with the AxisMotionProfileEnumeration. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=16674 + ns=3;i=16662 + ns=3;i=16668 + + + + ActualAcceleration + : The ActualAcceleration variable provides the axis acceleration. Applicable acceleration limits of the axis shall be provided by the EURange property of the AnalogUnitType. + + i=17497 + i=80 + ns=3;i=16679 + + + + EngineeringUnits + + i=68 + i=78 + + + + ActualPosition + The axis position inclusive Unit and RangeOfMotion. + + i=17497 + i=78 + ns=3;i=16667 + + + + EngineeringUnits + + i=68 + i=78 + + + + ActualSpeed + The axis speed on load side (after gear/spindle) inclusive Unit. + + i=17497 + i=80 + ns=3;i=16673 + + + + EngineeringUnits + + i=68 + i=78 + + + + ControllerType + The ControllerType describes the control unit of motion devices. One motion device system can have one or more instances of the ControllerType. + Rob System Monitor + Rob System Operation + Rob Program File Directory + Rob System Events + Rob Controller AM Extended + Rob Controller AM Extended + Rob MotionDeviceSystem Base + https://reference.opcfoundation.org/Robotics/v100/docs/7.18.2 + + ns=1;i=15063 + ns=3;i=18964 + ns=3;i=18918 + ns=3;i=6180 + ns=3;i=6181 + ns=3;i=17252 + ns=3;i=17249 + ns=3;i=6182 + ns=3;i=17237 + ns=3;i=17239 + ns=3;i=5004 + ns=3;i=17245 + ns=3;i=5054 + ns=3;i=17240 + ns=3;i=15800 + ns=3;i=5055 + ns=3;i=15826 + + + + <MotionDeviceIdentifier> + The relationship of a motion device and controller. + + ns=3;i=1004 + i=11508 + ns=3;i=19002 + ns=3;i=18985 + ns=3;i=18987 + ns=3;i=18998 + ns=3;i=18965 + ns=3;i=19080 + ns=3;i=18993 + ns=3;i=18988 + ns=3;i=6003 + + + + Axes + Axes is a container for one or more instances of the AxisType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.3/#7.2.3.10 + + i=61 + i=78 + ns=3;i=15743 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + MotionDeviceCategory + The variable MotionDeviceCategory provides the kind of motion device defined by MotionDeviceCategoryEnumeration based on ISO 8373. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=19001 + + + + SpeedOverride + SpeedOverride provides the current speed setting in percent of programmed speed (0 - 100%). + + i=63 + i=78 + + + + PowerTrains + PowerTrains is a container for one or more instances of the PowerTrainType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.3/#7.2.3.11 + + i=61 + i=78 + ns=3;i=15905 + + + + ProductCode + + i=68 + i=78 + + + + SerialNumber + + i=68 + i=78 + + + + TaskControlReference + + i=80 + i=63 + + + + <SafetyStatesIdentifier> + The relationship of safety states to a controller. + + ns=3;i=1013 + i=11508 + ns=3;i=18919 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=18962 + ns=3;i=18961 + ns=3;i=18963 + + + + EmergencyStop + The EmergencyStop variable is TRUE if one or more of the emergency stop functions in the robot system are active, FALSE otherwise. If the EmergencyStopFunctions object is provided, then the value of this variable is TRUE if one or more of the listed emergency stop functions are active. + + i=63 + i=78 + + + + OperationalMode + The OperationalMode variable provides information about the current operational mode. Allowed values are described in OperationalModeEnumeration, see ISO 10218-1:2011 Ch.5.7 Operational Modes. + + i=63 + i=78 + + + + ProtectiveStop + The ProtectiveStop variable is TRUE if one or more of the enabled protective stop functions in the system are active, FALSE otherwise. If the ProtectiveStopFunctions object is provided, then the value of this variable is TRUE if one or more of the listed protective stop functions are enabled and active. + + i=63 + i=78 + + + + AssetId + + i=68 + i=80 + + + + ComponentName + + i=68 + i=80 + + + + Components + Components is a container for one or more instances of subtypes of ComponentType defined in OPC UA DI. The listed components are installed in the motion device system, e.g. a processing-unit, a power-supply, an IO-board or a drive, and have an electrical interface to the controller. + https://reference.opcfoundation.org/Robotics/v100/docs/7.18.2 + + i=61 + i=80 + ns=3;i=18813 + + + + <ComponentIdentifier> + The intention is to integrate inside this container devices which are defined in other companion specifications using DI. + + i=11510 + ns=1;i=15063 + ns=1;i=15048 + ns=1;i=15035 + + + + CurrentUser + The current user of the system. + + ns=3;i=18175 + i=78 + ns=3;i=17250 + + + + Level + The weight of the load mounted on one mounting point. + + i=68 + i=78 + + + + DeviceManual + + i=68 + i=80 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=80 + ns=3;i=17365 + ns=3;i=17371 + ns=3;i=17377 + ns=3;i=15366 + ns=3;i=17383 + ns=3;i=17359 + ns=3;i=17358 + ns=3;i=15365 + + + + CabinetFanSpeed + The speed of the cabinet fan. + + i=17497 + i=80 + ns=3;i=17370 + + + + EngineeringUnits + + i=68 + i=78 + + + + CPUFanSpeed + The speed of the CPU fan. + + i=17497 + i=80 + ns=3;i=17376 + + + + EngineeringUnits + + i=68 + i=78 + + + + InputVoltage + The input voltage of the controller which can be a configured value. To distinguish between an AC or DC supply the optional property Definition of the base type DataItemType shall be used. + + i=17497 + i=80 + ns=3;i=17382 + + + + EngineeringUnits + + i=68 + i=78 + + + + StartUpTime + The date and time of the last start-up of the controller. + + i=63 + i=80 + + + + Temperature + The controller temperature given by a temperature sensor inside of the controller. + + i=17497 + i=80 + ns=3;i=17388 + + + + EngineeringUnits + + i=68 + i=78 + + + + TotalEnergyConsumption + The total accumulated energy consumed by the motion devices related with this controller instance. + + i=17497 + i=80 + ns=3;i=17364 + + + + EngineeringUnits + + i=68 + i=78 + + + + TotalPowerOnTime + The total accumulated time the controller was powered on. + + i=63 + i=80 + + + + UpsState + The vendor specific status of an integrated UPS or accumulator system. + + i=63 + i=80 + + + + ProductCode + + i=68 + i=78 + + + + Programs + + i=80 + i=13353 + ns=3;i=7015 + ns=3;i=7016 + ns=3;i=7017 + ns=3;i=7018 + + + + CreateDirectory + + i=78 + ns=3;i=6155 + ns=3;i=6156 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + DirectoryName + + i=12 + + -1 + + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + DirectoryNodeId + + i=17 + + -1 + + + + + + + + + + CreateFile + + i=78 + ns=3;i=6157 + ns=3;i=6158 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + FileName + + i=12 + + -1 + + + + + + + + i=297 + + + + RequestFileOpen + + i=1 + + -1 + + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + FileNodeId + + i=17 + + -1 + + + + + + + + i=297 + + + + FileHandle + + i=7 + + -1 + + + + + + + + + + Delete + + i=78 + ns=3;i=6159 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + ObjectToDelete + + i=17 + + -1 + + + + + + + + + + MoveOrCopy + + i=78 + ns=3;i=6160 + ns=3;i=6161 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + ObjectToMoveOrCopy + + i=17 + + -1 + + + + + + + + i=297 + + + + TargetDirectory + + i=17 + + -1 + + + + + + + + i=297 + + + + CreateCopy + + i=1 + + -1 + + + + + + + + i=297 + + + + NewName + + i=12 + + -1 + + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + NewNodeId + + i=17 + + -1 + + + + + + + + + + SerialNumber + + i=68 + i=78 + + + + Software + Software is a container for one or more instances of SoftwareType defined in OPC UA DI. + https://reference.opcfoundation.org/Robotics/v100/docs/7.8.3/#7.8.3.10 + + i=61 + i=78 + ns=3;i=18847 + + + + <SoftwareIdentifier> + + i=11510 + ns=1;i=15106 + ns=3;i=18868 + ns=3;i=18870 + ns=3;i=18873 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + SoftwareRevision + + i=68 + i=78 + + + + SystemOperation + + i=80 + ns=3;i=1028 + ns=3;i=5056 + + + + SystemOperationStateMachine + + i=78 + ns=3;i=1021 + ns=3;i=6162 + ns=3;i=6164 + ns=3;i=6166 + + + + CurrentState + + i=78 + i=2760 + ns=3;i=6163 + + + + Id + + i=78 + i=68 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6165 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6167 + ns=3;i=6168 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + TaskControls + TaskControls is a container for one or more instances of TaskControlType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.8.3/#7.8.3.11 + + i=61 + i=78 + ns=3;i=18881 + + + + <TaskControlIdentifier> + + ns=3;i=1011 + i=11510 + ns=3;i=18914 + ns=3;i=18882 + + + + ComponentName + A user writable name provided by the vendor, integrator or user of the device. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=18916 + ns=3;i=18915 + + + + TaskProgramLoaded + The TaskProgramLoaded variable is TRUE if a task program is loaded in the task control, FALSE otherwise. + + i=63 + i=78 + + + + TaskProgramName + A customer given identifier for the task program. + + i=63 + i=78 + + + + DriveType + Drives (multi-slot or single-slot axis amplifier) mounted in a controller cabinet or a motion device. + https://reference.opcfoundation.org/Robotics/v100/docs/7.20.2 + + ns=1;i=15063 + ns=3;i=6184 + ns=3;i=17824 + + + + AssetId + + i=68 + i=80 + + + + ProductCode + The ProductCode property provides a unique combination of numbers and letters used to identify the product. It may be the order information displayed on type shields or in ERP systems. + + i=68 + i=78 + + + + GearType + The GearType describes a gear in a powertrain, e.g. a gear box or a spindle. + Rob Gear CM Extended + Rob Gear AM Extended + https://reference.opcfoundation.org/Robotics/v100/docs/7.6.2 + + ns=1;i=15063 + ns=3;i=6178 + ns=3;i=15941 + ns=3;i=17152 + ns=3;i=17154 + ns=3;i=17165 + ns=3;i=17160 + ns=3;i=17155 + + + + AssetId + + i=68 + i=80 + + + + GearRatio + The transmission ratio of the gear expressed as a fraction as input velocity (motor side) by output velocity (load side). + + i=17709 + i=78 + ns=3;i=15616 + ns=3;i=15615 + + + + Denominator + + i=63 + i=78 + + + + Numerator + + i=63 + i=78 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + Pitch + Pitch describes the distance covered in millimeters (mm) for linear motion per one revolution of the output side of the driving unit. Pitch is used in combination with GearRatio to describe the overall transmission from input to output of the gear. + + i=63 + i=80 + + + + ProductCode + + i=68 + i=78 + + + + SerialNumber + + i=68 + i=78 + + + + MotionDeviceSystemType + Contains the set of controllers and motion devices in a closely-coupled motion device system. + Rob MotionDeviceSystem Base + https://reference.opcfoundation.org/Robotics/v100/docs/7.1.2 + + ns=1;i=15063 + ns=3;i=6171 + ns=3;i=5001 + ns=3;i=5002 + ns=3;i=5010 + + + + ComponentName + + i=68 + i=80 + + + + Controllers + Contains the set of controllers in the motion device system. + https://reference.opcfoundation.org/Robotics/v100/docs/7.1.2 + + i=61 + i=78 + ns=3;i=15405 + + + + <ControllerIdentifier> + + ns=3;i=1003 + i=11510 + ns=3;i=15440 + ns=3;i=15426 + ns=3;i=15428 + ns=3;i=15434 + ns=3;i=15429 + ns=3;i=15483 + ns=3;i=15518 + + + + CurrentUser + The given name of the device. + + ns=3;i=18175 + i=78 + ns=3;i=15441 + + + + Level + The weight of the load mounted on one mounting point. + + i=68 + i=78 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + ProductCode + + i=68 + i=78 + + + + SerialNumber + + i=68 + i=78 + + + + Software + Software is a container for one or more instances of SoftwareType defined in OPC UA DI. + https://reference.opcfoundation.org/Robotics/v100/docs/7.8.3/#7.8.3.10 + + i=61 + i=78 + ns=3;i=18847 + + + + TaskControls + TaskControls is a container for one or more instances of TaskControlType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.8.3/#7.8.3.11 + + i=61 + i=78 + ns=3;i=18881 + + + + MotionDevices + Contains any kinematic or motion device which is part of the motion device system. + https://reference.opcfoundation.org/Robotics/v100/docs/7.1.2 + + i=61 + i=78 + ns=3;i=15008 + + + + <MotionDeviceIdentifier> + + ns=3;i=1004 + i=11510 + ns=3;i=15062 + ns=3;i=15045 + ns=3;i=15047 + ns=3;i=15058 + ns=3;i=15024 + ns=3;i=15208 + ns=3;i=15053 + ns=3;i=15048 + ns=3;i=6002 + + + + Axes + Axes is a container for one or more instances of the AxisType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.3/#7.2.3.10 + + i=61 + i=78 + ns=3;i=15743 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + MotionDeviceCategory + The variable MotionDeviceCategory provides the kind of motion device defined by MotionDeviceCategoryEnumeration based on ISO 8373. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=15061 + + + + SpeedOverride + SpeedOverride provides the current speed setting in percent of programmed speed (0 - 100%). + + i=63 + i=78 + + + + PowerTrains + PowerTrains is a container for one or more instances of the PowerTrainType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.3/#7.2.3.11 + + i=61 + i=78 + ns=3;i=15905 + + + + ProductCode + + i=68 + i=78 + + + + SerialNumber + + i=68 + i=78 + + + + TaskControlReference + + i=80 + i=63 + + + + SafetyStates + Contains safety-related data from motion device system. + https://reference.opcfoundation.org/Robotics/v100/docs/7.1.2 + + i=61 + i=78 + ns=3;i=15697 + + + + <SafetyStateIdentifier> + + ns=3;i=1013 + i=11510 + ns=3;i=15698 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=15741 + ns=3;i=15740 + ns=3;i=15742 + + + + EmergencyStop + The EmergencyStop variable is TRUE if one or more of the emergency stop functions in the robot system are active, FALSE otherwise. If the EmergencyStopFunctions object is provided, then the value of this variable is TRUE if one or more of the listed emergency stop functions are active. + + i=63 + i=78 + + + + OperationalMode + The OperationalMode variable provides information about the current operational mode. Allowed values are described in OperationalModeEnumeration, see ISO 10218-1:2011 Ch.5.7 Operational Modes. + + i=63 + i=78 + + + + ProtectiveStop + The ProtectiveStop variable is TRUE if one or more of the enabled protective stop functions in the system are active, FALSE otherwise. If the ProtectiveStopFunctions object is provided, then the value of this variable is TRUE if one or more of the listed protective stop functions are enabled and active. + + i=63 + i=78 + + + + MotionDeviceType + Represents a specific motion device in the motion device system like a robot, a linear unit or a positioner. A MotionDevice should have at least one axis. + Rob MotionDeviceSystem Base + Rob MotionDevice AM Extended + Rob MotionDevice CM Extended + Rob MotionDevice Flangeload + Rob TC Relationship + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.2 + + ns=1;i=15063 + ns=3;i=16566 + ns=3;i=6172 + ns=3;i=15305 + ns=3;i=6173 + ns=3;i=6174 + ns=3;i=5091 + ns=3;i=16351 + ns=3;i=16353 + ns=3;i=16362 + ns=3;i=5029 + ns=3;i=16443 + ns=3;i=16359 + ns=3;i=16354 + ns=3;i=6001 + + + + AdditionalComponents + AdditionalComponents is a container for one or more instances of subtypes of ComponentType defined in OPC UA DI. The listed components are installed at the motion device, e.g. an IO-board. + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.3/#7.2.3.13 + + i=61 + i=80 + ns=3;i=5003 + + + + <AdditionalComponentIdentifier> + + i=11510 + i=58 + + + + AssetId + + i=68 + i=80 + + + + Axes + Axes is a container for one or more instances of the AxisType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.3/#7.2.3.10 + + i=61 + i=78 + ns=3;i=15743 + + + + <AxisIdentifier> + + ns=3;i=16601 + i=11510 + ns=3;i=15808 + ns=3;i=15744 + + + + MotionProfile + The kind of axis motion as defined with the AxisMotionProfileEnumeration. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=15863 + + + + ActualPosition + The axis position inclusive Unit and RangeOfMotion. + + i=17497 + i=78 + ns=3;i=15869 + + + + EngineeringUnits + + i=68 + i=78 + + + + ComponentName + + i=68 + i=80 + + + + DeviceManual + + i=68 + i=80 + + + + FlangeLoad + The FlangeLoad is the load on the flange or at the mounting point of the MotionDevice. This can be the maximum load of the MotionDevice. + + ns=3;i=1018 + i=80 + ns=3;i=6624 + + + + Mass + The weight of the load mounted on one mounting point. + + i=17497 + i=78 + ns=3;i=15659 + + + + EngineeringUnits + + i=68 + i=78 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + MotionDeviceCategory + The variable MotionDeviceCategory provides the kind of motion device defined by MotionDeviceCategoryEnumeration based on ISO 8373. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=16364 + ns=3;i=16363 + ns=3;i=16365 + + + + InControl + InControl provides the information if the actuators (in most cases a motor) of the motion device are powered up and in control: "true". The motion device might be in a standstill. + + i=63 + i=80 + + + + OnPath + OnPath is true if the motion device is on or near enough the planned program path such that program execution can continue. If the MotionDevice deviates too much from this path in case of errors or an emergency stop, this value becomes false. If OnPath is false, the motion device needs repositioning to continue program execution. + + i=63 + i=80 + + + + SpeedOverride + SpeedOverride provides the current speed setting in percent of programmed speed (0 - 100%). + + i=63 + i=78 + + + + PowerTrains + PowerTrains is a container for one or more instances of the PowerTrainType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.3/#7.2.3.11 + + i=61 + i=78 + ns=3;i=15905 + + + + <PowerTrainIdentifier> + + ns=3;i=16794 + i=11510 + ns=3;i=15998 + + + + ProductCode + + i=68 + i=78 + + + + SerialNumber + + i=68 + i=78 + + + + TaskControlReference + + i=80 + i=63 + + + + MotorType + The MotorType is for representing instances of electric motors. + Rob MotionDeviceSystem Base + Rob Motor AM Extended + Rob Motor CM Extended + https://reference.opcfoundation.org/Robotics/v100/docs/7.5.2 + + ns=1;i=15063 + ns=3;i=5060 + ns=3;i=6177 + ns=3;i=17101 + ns=3;i=17103 + ns=3;i=5105 + ns=3;i=17109 + ns=3;i=17104 + + + + <DriveIdentifier> + + i=11508 + i=58 + + + + AssetId + + i=68 + i=80 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=17150 + ns=3;i=6776 + ns=3;i=6757 + + + + BrakeReleased + Indicates an optional variable used only for motors with brakes. If BrakeReleased is TRUE the motor is free to run. FALSE means that the motor shaft is locked by the brake. + + i=63 + i=80 + + + + EffectiveLoadRate + EffectiveLoadRate is expressed as a percentage of maximum continuous load. The Joule integral is typically used to calculate the current load. Duration should be defined and documented by the vendor. + + i=63 + i=80 + + + + MotorTemperature + The motor temperature provides the temperature of the motor. If there is no temperature sensor the value is set to \"null\". + + i=17497 + i=78 + ns=3;i=6762 + + + + EngineeringUnits + + i=68 + i=78 + + + + ProductCode + + i=68 + i=78 + + + + SerialNumber + + i=68 + i=78 + + + + PowerTrainType + The PowerTrainType represents instances of powertrains of a motion device. + Rob MotionDeviceSystem Base + Rob PowerTrain AM Extended + https://reference.opcfoundation.org/Robotics/v100/docs/7.4.2 + + ns=1;i=15063 + ns=3;i=18536 + ns=3;i=16041 + ns=3;i=15998 + ns=3;i=18613 + ns=3;i=6176 + + + + <AxisIdentifier> + Moves is a reference to provide the relationship of powertrains to axes. For complex kinematics this does not need to be a one to one relationship, because a powertrain might influence the motion of more than one axis. This reference connects all axis to a powertrain that that move when only this powertrain moves and all other powertrains stand still. + + ns=3;i=16601 + i=11508 + ns=3;i=18570 + ns=3;i=18537 + + + + MotionProfile + The kind of axis motion as defined with the AxisMotionProfileEnumeration. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=18595 + + + + ActualPosition + The axis position inclusive Unit and RangeOfMotion. + + i=17497 + i=78 + ns=3;i=18600 + + + + EngineeringUnits + + i=68 + i=78 + + + + <GearIdentifier> + + ns=3;i=1022 + i=11508 + ns=3;i=16076 + ns=3;i=16062 + ns=3;i=16064 + ns=3;i=16068 + ns=3;i=16071 + + + + GearRatio + The transmission ratio of the gear expressed as a fraction as input velocity (motor side) by output velocity (load side). + + i=17709 + i=78 + ns=3;i=16078 + ns=3;i=16077 + + + + Denominator + + i=63 + i=78 + + + + Numerator + + i=63 + i=78 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + ProductCode + + i=68 + i=78 + + + + SerialNumber + + i=68 + i=78 + + + + <MotorIdentifier> + + ns=3;i=1019 + i=11510 + ns=3;i=16019 + ns=3;i=16021 + ns=3;i=15999 + ns=3;i=16025 + ns=3;i=16028 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=16034 + + + + MotorTemperature + The motor temperature provides the temperature of the motor. If there is no temperature sensor the value is set to \"null\". + + i=17497 + i=78 + ns=3;i=16039 + + + + EngineeringUnits + + i=68 + i=78 + + + + ProductCode + + i=68 + i=78 + + + + SerialNumber + + i=68 + i=78 + + + + <PowerTrainIdentifier> + HasSlave is a reference to provide the master-slave relationship of powertrains which provide torque for a common axis. The InverseName is IsSlaveOf. + + ns=3;i=16794 + i=11508 + ns=3;i=15998 + + + + ComponentName + + i=68 + i=80 + + + + SafetyStateType + SafetyStateType describes the safety states of the motion devices and controllers. One motion device system is associated with one or more instances of the SafetyStateType. + Rob MotionDeviceSystem Base + Rob Emergency Stop Function + Rob Protective Stop Function + https://reference.opcfoundation.org/Robotics/v100/docs/7.7.2 + + ns=1;i=15063 + ns=3;i=6179 + ns=3;i=17221 + ns=3;i=5016 + ns=3;i=17225 + + + + ComponentName + + i=68 + i=80 + + + + EmergencyStopFunctions + EmergencyStopFunctions is a container for one or more instances of the EmergencyStopFunctionType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.7.3/#7.7.3.2 + + i=61 + i=80 + ns=3;i=18806 + + + + <EmergencyStopFunctionIdentifier> + + ns=3;i=17230 + i=11510 + ns=3;i=18808 + ns=3;i=18807 + + + + Active + The Active variable is TRUE if this particular emergency stop function is active, e.g. that the emergency stop button is pressed, FALSE otherwise. + + i=63 + i=78 + + + + Name + The Name of the EmergencyStopFunctionType provides a manufacturer-specific emergency stop function identifier within the safety system. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=15882 + ns=3;i=15912 + ns=3;i=15913 + + + + EmergencyStop + The EmergencyStop variable is TRUE if one or more of the emergency stop functions in the robot system are active, FALSE otherwise. If the EmergencyStopFunctions object is provided, then the value of this variable is TRUE if one or more of the listed emergency stop functions are active. + + i=63 + i=78 + + + + OperationalMode + The OperationalMode variable provides information about the current operational mode. Allowed values are described in OperationalModeEnumeration, see ISO 10218-1:2011 Ch.5.7 Operational Modes. + + i=63 + i=78 + + + + ProtectiveStop + The ProtectiveStop variable is TRUE if one or more of the enabled protective stop functions in the system are active, FALSE otherwise. If the ProtectiveStopFunctions object is provided, then the value of this variable is TRUE if one or more of the listed protective stop functions are enabled and active. + + i=63 + i=78 + + + + ProtectiveStopFunctions + ProtectiveStopFunctions is a container for one or more instances of the ProtectiveStopFunctionType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.7.3/#7.7.3.3 + + i=61 + i=80 + ns=3;i=18809 + + + + <ProtectiveStopFunctionIdentifier> + + ns=3;i=17233 + i=11510 + ns=3;i=18812 + ns=3;i=18811 + ns=3;i=18810 + + + + Active + โ€“ The Active variable is TRUE if this particular protective stop function is active, i.e. that a stop is initiated, FALSE otherwise. If Enabled is FALSE then Active shall be FALSE. + + i=63 + i=78 + + + + Enabled + โ€“ The Enabled variable is TRUE if this protective stop function is currently supervising the system, FALSE otherwise. A protective stop function may or may not be enabled at all times, e.g. the protective stop function of the safety doors are typically enabled in automatic operational mode and disabled in manual mode. On the other hand for example, the protective stop function of the teach pendant enabling device is enabled in manual modes and disabled in automatic modes. + + i=63 + i=78 + + + + Name + The Name of the ProtectiveStopFunctionType provides a manufacturer-specific protective stop function identifier within the safety system. + + i=68 + i=78 + + + + TaskControlType + Represents a specific task control active on the controller. + Rob Task Control CM Extended + Rob Task Control Monitor + Rob Task Control Operation + Rob Task Control Modules + Rob MotionDeviceSystem Base + https://reference.opcfoundation.org/Robotics/v100/docs/7.21.2 + + ns=1;i=15063 + ns=3;i=19254 + ns=3;i=17873 + ns=3;i=15883 + ns=3;i=5052 + ns=3;i=5057 + + + + <MotionDeviceIdentifier> + Controls is a reference to provide the relationship between a task control and a motion device. + + ns=3;i=1004 + i=11508 + ns=3;i=19292 + ns=3;i=19275 + ns=3;i=19277 + ns=3;i=19288 + ns=3;i=19255 + ns=3;i=19370 + ns=3;i=19283 + ns=3;i=19278 + ns=3;i=6004 + + + + Axes + Axes is a container for one or more instances of the AxisType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.3/#7.2.3.10 + + i=61 + i=78 + ns=3;i=15743 + + + + Manufacturer + + i=68 + i=78 + + + + Model + + i=68 + i=78 + + + + MotionDeviceCategory + The variable MotionDeviceCategory provides the kind of motion device defined by MotionDeviceCategoryEnumeration based on ISO 8373. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=19291 + + + + SpeedOverride + SpeedOverride provides the current speed setting in percent of programmed speed (0 - 100%). + + i=63 + i=78 + + + + PowerTrains + PowerTrains is a container for one or more instances of the PowerTrainType. + https://reference.opcfoundation.org/Robotics/v100/docs/7.2.3/#7.2.3.11 + + i=61 + i=78 + ns=3;i=15905 + + + + ProductCode + + i=68 + i=78 + + + + SerialNumber + + i=68 + i=78 + + + + TaskControlReference + + i=80 + i=63 + + + + ComponentName + A user writable name provided by the vendor, integrator or user of the device. + + i=68 + i=78 + + + + ParameterSet + Flat list of Parameters + + i=58 + i=78 + ns=3;i=17876 + ns=3;i=17875 + ns=3;i=17874 + + + + ExecutionMode + Execution mode of the task control (continuous or step-wise). + + i=63 + i=80 + + + + TaskProgramLoaded + The TaskProgramLoaded variable is TRUE if a task program is loaded in the task control, FALSE otherwise. + + i=63 + i=78 + + + + TaskProgramName + A customer given identifier for the task program. + + i=63 + i=78 + + + + TaskControlOperation + + i=80 + ns=3;i=1008 + ns=3;i=5053 + + + + TaskControlStateMachine + + i=78 + ns=3;i=1025 + ns=3;i=6148 + ns=3;i=6150 + ns=3;i=6152 + + + + CurrentState + + i=78 + i=2760 + ns=3;i=6149 + + + + Id + + i=78 + i=68 + + + + LastTransition + + i=78 + i=2767 + ns=3;i=6151 + + + + Id + + i=78 + i=68 + + + + LastTransitionReason + + i=78 + i=11238 + ns=3;i=6153 + ns=3;i=6154 + + + + EnumValues + + i=78 + i=68 + + + + + + i=7616 + + + + 0 + + en + Unknown + + + en + Caused by an unknown reason + + + + + + + i=7616 + + + + 1 + + en + External + + + en + Caused by external operation + + + + + + + i=7616 + + + + 2 + + en + Direct + + + en + Caused by direct operation + + + + + + + i=7616 + + + + 3 + + en + System + + + en + Caused by system specific behavior + + + + + + + i=7616 + + + + 4 + + en + Error + + + en + Caused by an error + + + + + + + i=7616 + + + + 5 + + en + Application + + + en + Caused explicitly by end user program logic + + + + + + + + + ValueAsText + + i=78 + i=68 + + + + Invalid + + + + + TaskModules + + i=80 + i=61 + ns=3;i=5058 + + + + <TaskModule> + + ns=3;i=1016 + i=11508 + ns=3;i=6169 + + + + Name + + i=68 + i=78 + + + + UserType + The UserType ObjectType describes information of the registered user groups within the control system. + Rob MotionDeviceSystem Base + https://reference.opcfoundation.org/Robotics/v100/docs/7.24.2 + + i=58 + ns=3;i=18176 + ns=3;i=18177 + + + + Level + Provides information about the access rights and determines what can be viewed, updated, or deleted by a user + + i=68 + i=78 + + + + Name + The name for the current user within the control system. + + i=68 + i=80 + + + + http://opcfoundation.org/UA/Robotics/ + https://reference.opcfoundation.org/Robotics/v100/docs/12.1 + + i=11715 + i=11616 + + + + DefaultAccessRestrictions + + ns=3;i=15011 + i=68 + + + i=15704 + i=15644 + + + + DefaultRolePermissions + + ns=3;i=15011 + i=68 + + + i=15704 + i=15644 + + + + DefaultUserRolePermissions + + ns=3;i=15011 + i=68 + + + + IsNamespaceSubset + + ns=3;i=15011 + i=68 + + + false + + + + NamespacePublicationDate + + ns=3;i=15011 + i=68 + + + 2025-09-08T00:00:00Z + + + + NamespaceUri + + ns=3;i=15011 + i=68 + + + http://opcfoundation.org/UA/Robotics/ + + + + NamespaceVersion + + ns=3;i=15011 + i=68 + + + 1.02 + + + + StaticNodeIdTypes + + ns=3;i=15011 + i=68 + + + + 0 + + + + + StaticNumericNodeIdRange + + ns=3;i=15011 + i=68 + + + + StaticStringNodeIdPattern + + ns=3;i=15011 + i=68 + + + diff --git a/src/Opc.Ua.Robotics/NugetREADME.md b/src/Opc.Ua.Robotics/NugetREADME.md new file mode 100644 index 0000000000..cffad4ce92 --- /dev/null +++ b/src/Opc.Ua.Robotics/NugetREADME.md @@ -0,0 +1,21 @@ +# Opc.Ua.Robotics + +Server/client-independent foundation for the **OPC 40010 Robotics** companion +specification. + +The Robotics NodeSet and its required Industrial Automation (IA) base model are +**source-generated** here (over the source-generated OPC UA DI base model), +exposing generated ObjectTypes, ReferenceTypes, enums, typed node states and +client proxies, plus the `AddOpcUaRobotics` / `AddOpcUaIA` model loaders. The +generated `ObjectTypeIds` and `ReferenceTypeIds` classes are the source of truth; +`RoboticsModel` provides namespace-safe resolution and classification helpers. + +The package also provides `ArrayOf`-based common contracts for a focused read +projection of systems, controllers, motion devices, axes, loads, power trains, +motors, gears, drives, safety states, task controls, task modules, engineering +values, telemetry, and semantic relationships. Containment identifiers remain +on owning instances, while `RoboticsRelationshipSnapshot` is the authoritative +projection of semantic Robotics references. These contracts can be shared +without taking a Server or Client dependency. + +See the [Robotics developer guide](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/Robotics.md). diff --git a/src/Opc.Ua.Robotics/Opc.Ua.Robotics.csproj b/src/Opc.Ua.Robotics/Opc.Ua.Robotics.csproj new file mode 100644 index 0000000000..eb98a80a5e --- /dev/null +++ b/src/Opc.Ua.Robotics/Opc.Ua.Robotics.csproj @@ -0,0 +1,64 @@ + + + $(AssemblyPrefix).Robotics + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.Robotics + Opc.Ua.Robotics + $(NoWarn);CS1591;CS0108 + enable + Server/client-independent OPC 40010 Robotics contracts and source-generated Robotics and Industrial Automation models over OPC UA DI, including model identifiers, typed states and proxies, model loaders, and focused topology read-projection contracts. + true + NugetREADME.md + MIT + true + true + + + $(PackageId).Debug + + + + + + + + + + + + + + Analyzer + false + + + + + + + + + + Opc.Ua.IA + + + Opc.Ua.Robotics + + + + + v105 + true + true + + + + + diff --git a/src/Opc.Ua.Robotics/Properties/AssemblyInfo.cs b/src/Opc.Ua.Robotics/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.Robotics/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.Robotics/RoboticsModel.cs b/src/Opc.Ua.Robotics/RoboticsModel.cs new file mode 100644 index 0000000000..a92fcce269 --- /dev/null +++ b/src/Opc.Ua.Robotics/RoboticsModel.cs @@ -0,0 +1,298 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.Robotics +{ + /// + /// Resolves and classifies generated OPC 40010 Robotics ObjectTypes and + /// ReferenceTypes against a server namespace table. + /// + /// + /// The generated and + /// classes are the source of truth for the model. The numeric constants in + /// this class are retained for compatibility with earlier package versions. + /// + public static class RoboticsModel + { + /// + /// The numeric identifier of the generated MotionDeviceSystemType. + /// + public const uint MotionDeviceSystemType = 1002; + + /// + /// The numeric identifier of the generated ControllerType. + /// + public const uint ControllerType = 1003; + + /// + /// The numeric identifier of the generated MotionDeviceType. + /// + public const uint MotionDeviceType = 1004; + + /// + /// The numeric identifier of the generated AxisType. + /// + public const uint AxisType = 16601; + + /// + /// Resolves a numeric Robotics identifier into the Robotics namespace + /// in the supplied namespace table. + /// + /// + /// A Robotics numeric identifier, such as . + /// + /// The server's namespace table. + /// The resolved NodeId. + public static NodeId TypeNodeId(uint identifier, NamespaceTable namespaceUris) + { + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + + return NodeId.Create(identifier, global::Opc.Ua.Robotics.Namespaces.Robotics, namespaceUris); + } + + /// + /// Resolves a generated Robotics ObjectType identifier against a server + /// namespace table. + /// + /// + /// An identifier from the generated class. + /// + /// The server's namespace table. + /// The resolved NodeId, or when unresolved. + public static NodeId ObjectTypeNodeId( + ExpandedNodeId identifier, + NamespaceTable namespaceUris) + { + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + + if (!ObjectTypeIds.TryGetBrowseName(identifier, out _)) + { + return NodeId.Null; + } + + return ResolveNodeId(identifier, namespaceUris); + } + + /// + /// Resolves a generated Robotics ReferenceType identifier against a + /// server namespace table. + /// + /// + /// An identifier from the generated class. + /// + /// The server's namespace table. + /// The resolved NodeId, or when unresolved. + public static NodeId ReferenceTypeNodeId( + ExpandedNodeId identifier, + NamespaceTable namespaceUris) + { + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + + if (!ReferenceTypeIds.TryGetBrowseName(identifier, out _)) + { + return NodeId.Null; + } + + return ResolveNodeId(identifier, namespaceUris); + } + + /// + /// Resolves a generated Robotics ObjectType by browse name. + /// + /// + /// The generated browse name, with or without the trailing Type. + /// + /// The server's namespace table. + /// The resolved ObjectType NodeId. + /// + /// true when the name is generated by the Robotics model and its + /// namespace is present in . + /// + public static bool TryGetObjectTypeNodeId( + string typeName, + NamespaceTable namespaceUris, + out NodeId typeId) + { + if (typeName is null) + { + throw new ArgumentNullException(nameof(typeName)); + } + + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + + string browseName = typeName.EndsWith("Type", StringComparison.Ordinal) ? + typeName : + typeName + "Type"; + if (ObjectTypeIds.TryGetValue(browseName, out ExpandedNodeId identifier)) + { + typeId = ResolveNodeId(identifier, namespaceUris); + return !typeId.IsNull; + } + + typeId = NodeId.Null; + return false; + } + + /// + /// Resolves a generated Robotics ReferenceType by browse name. + /// + /// The generated ReferenceType browse name. + /// The server's namespace table. + /// The resolved ReferenceType NodeId. + /// + /// true when the name is generated by the Robotics model and its + /// namespace is present in . + /// + public static bool TryGetReferenceTypeNodeId( + string referenceTypeName, + NamespaceTable namespaceUris, + out NodeId referenceTypeId) + { + if (referenceTypeName is null) + { + throw new ArgumentNullException(nameof(referenceTypeName)); + } + + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + + if (ReferenceTypeIds.TryGetValue( + referenceTypeName, + out ExpandedNodeId identifier)) + { + referenceTypeId = ResolveNodeId(identifier, namespaceUris); + return !referenceTypeId.IsNull; + } + + referenceTypeId = NodeId.Null; + return false; + } + + /// + /// Classifies a NodeId as any ObjectType generated from the Robotics + /// NodeSet and returns its friendly type name. + /// + /// The TypeDefinition NodeId to classify. + /// The server's namespace table. + /// + /// The generated browse name without the trailing Type. + /// + /// true when the NodeId is a generated Robotics ObjectType. + public static bool TryGetRoboticsTypeName( + NodeId typeDefinition, + NamespaceTable namespaceUris, + out string? name) + { + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + + name = null; + int namespaceIndex = namespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics); + if (namespaceIndex < 0 || + typeDefinition.NamespaceIndex != namespaceIndex || + !typeDefinition.TryGetValue(out uint identifier)) + { + return false; + } + + var expandedNodeId = new ExpandedNodeId(identifier, global::Opc.Ua.Robotics.Namespaces.Robotics); + if (!ObjectTypeIds.TryGetBrowseName(expandedNodeId, out string? browseName) || + string.IsNullOrEmpty(browseName)) + { + return false; + } + + name = browseName.EndsWith("Type", StringComparison.Ordinal) ? + browseName.Substring(0, browseName.Length - 4) : + browseName; + return true; + } + + /// + /// Classifies a NodeId as any ReferenceType generated from the Robotics + /// NodeSet and returns its browse name. + /// + /// The ReferenceType NodeId to classify. + /// The server's namespace table. + /// The generated ReferenceType browse name. + /// true when the NodeId is a generated Robotics ReferenceType. + public static bool TryGetRoboticsReferenceTypeName( + NodeId referenceTypeId, + NamespaceTable namespaceUris, + out string? name) + { + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + + name = null; + int namespaceIndex = namespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics); + if (namespaceIndex < 0 || + referenceTypeId.NamespaceIndex != namespaceIndex || + !referenceTypeId.TryGetValue(out uint identifier)) + { + return false; + } + + var expandedNodeId = new ExpandedNodeId(identifier, global::Opc.Ua.Robotics.Namespaces.Robotics); + return ReferenceTypeIds.TryGetBrowseName(expandedNodeId, out name); + } + + private static NodeId ResolveNodeId( + ExpandedNodeId identifier, + NamespaceTable namespaceUris) + { + if (namespaceUris is null) + { + throw new ArgumentNullException(nameof(namespaceUris)); + } + + return ExpandedNodeId.ToNodeId(identifier, namespaceUris); + } + } +} diff --git a/src/Opc.Ua.Robotics/RoboticsOperationContracts.cs b/src/Opc.Ua.Robotics/RoboticsOperationContracts.cs new file mode 100644 index 0000000000..0b3641d68e --- /dev/null +++ b/src/Opc.Ua.Robotics/RoboticsOperationContracts.cs @@ -0,0 +1,157 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics +{ + /// + /// Standard OPC 40010 operation states. + /// + public enum RoboticsOperationState + { + /// + /// The operation is idle. + /// + Idle, + + /// + /// The operation is ready. + /// + Ready, + + /// + /// The operation is executing. + /// + Executing + } + + /// + /// Standard stop mode values passed to OPC 40010 Stop methods. + /// + public enum RoboticsStopMode : short + { + /// + /// The default stop mode configured by the server. + /// + Default = 0, + + /// + /// A controlled stop mode. + /// + Controlled = 1, + + /// + /// A quick stop mode. + /// + Quick = 2, + + /// + /// An immediate stop mode. + /// + Immediate = 3 + } + + /// + /// Context passed to standard operation method handlers. + /// + public sealed record RoboticsOperationContext + { + /// + /// The operation object NodeId. + /// + public NodeId OperationNodeId { get; init; } = NodeId.Null; + + /// + /// The operation state machine NodeId. + /// + public NodeId StateMachineNodeId { get; init; } = NodeId.Null; + + /// + /// The state observed before the method handler was invoked. + /// + public RoboticsOperationState CurrentState { get; init; } + } + + /// + /// Request data passed to standard Stop method handlers. + /// + public sealed record RoboticsStopRequest + { + /// + /// The operation context. + /// + public RoboticsOperationContext Context { get; init; } = new(); + + /// + /// The requested stop mode. + /// + public RoboticsStopMode StopMode { get; init; } + } + + /// + /// Describes a completed operation state transition. + /// + public sealed record RoboticsOperationTransition + { + /// + /// The state before the transition. + /// + public RoboticsOperationState FromState { get; init; } + + /// + /// The state after the transition. + /// + public RoboticsOperationState ToState { get; init; } + + /// + /// The transition NodeId. + /// + public NodeId TransitionId { get; init; } = NodeId.Null; + + /// + /// The transition reason written to LastTransitionReason. + /// + public short Reason { get; init; } + } + + /// + /// Result returned by task-control program load and unload handlers. + /// + public sealed record RoboticsProgramResult + { + /// + /// The OPC UA service result for the method call. + /// + public ServiceResult ServiceResult { get; init; } = ServiceResult.Good; + + /// + /// The method-specific status output. + /// + public int Status { get; init; } + } +} diff --git a/src/Opc.Ua.Robotics/RoboticsOperationConventions.cs b/src/Opc.Ua.Robotics/RoboticsOperationConventions.cs new file mode 100644 index 0000000000..4c4649e827 --- /dev/null +++ b/src/Opc.Ua.Robotics/RoboticsOperationConventions.cs @@ -0,0 +1,159 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua.Robotics.Operations +{ + /// + /// Describes how a release operation should transfer the held object. + /// + public enum RoboticsReleaseMode + { + /// + /// Release the object without controlled placement. + /// + Drop, + + /// + /// Place the object at the requested or current target. + /// + Place, + + /// + /// Hold the object for a handover and release when the application decides it is safe. + /// + Handover + } + + /// + /// Describes the approach direction convention used by grasping and placement operations. + /// + public enum RoboticsApproach + { + /// + /// The server or application chooses the approach. + /// + Default, + + /// + /// Approach along the tool Z axis. + /// + ToolZ, + + /// + /// Approach from above in the active work coordinate system. + /// + Top, + + /// + /// Approach from the side in the active work coordinate system. + /// + Side + } + + /// + /// Requests a non-normative convention MoveTo operation. + /// + public sealed record MoveToRequest( + ThreeDFrame TargetFrame, + double? SpeedFraction = null, + double? BlendRadius = null, + EUInformation? BlendRadiusUnits = null); + + /// + /// Requests a non-normative convention joint move. + /// + public sealed record JointMoveRequest( + ArrayOf JointTargets, + EUInformation JointUnits, + double? SpeedFraction = null); + + /// + /// Requests a non-normative convention linear move. + /// + public sealed record LinearMoveRequest( + ThreeDFrame TargetFrame, + double LinearSpeed, + EUInformation LinearSpeedUnits, + double? Acceleration = null, + EUInformation? AccelerationUnits = null); + + /// + /// Requests a non-normative convention grasp operation. + /// + public sealed record GraspRequest( + double? ForceNewtons = null, + double? Width = null, + EUInformation? WidthUnits = null, + RoboticsApproach Approach = RoboticsApproach.Default); + + /// + /// Requests a non-normative convention release operation. + /// + public sealed record ReleaseRequest( + RoboticsReleaseMode Mode, + ThreeDFrame? TargetFrame = null); + + /// + /// Requests a non-normative convention pick or place operation. + /// + public sealed record PickPlaceRequest( + string StationOrLocationIdentifier, + string ObjectClass, + ArrayOf Attributes, + double? ForceNewtons = null); + + /// + /// Requests a non-normative convention tool-change operation. + /// + public sealed record ToolChangeRequest(string ToolIdentifier, string? DockStation = null); + + /// + /// Requests a non-normative convention output write operation. + /// + public sealed record OutputRequest(string OutputLineIdentifier, Variant Value); + + /// + /// Requests a non-normative convention program call operation. + /// + public sealed record ProgramCallRequest(string ProgramName, ArrayOf Arguments); + + /// + /// Describes the result of a non-normative convention Robotics operation. + /// + public sealed record RoboticsOperationResult( + ServiceResult ServiceResult, + string? Message = null, + ArrayOf? Outputs = null) + { + /// + /// Gets a successful operation result. + /// + public static RoboticsOperationResult Good { get; } = new(ServiceResult.Good); + } +} diff --git a/src/Opc.Ua.Server/FileSystem/DirectoryBrowser.cs b/src/Opc.Ua.Server/FileSystem/DirectoryBrowser.cs index 64ad7edbf5..d9d0041edd 100644 --- a/src/Opc.Ua.Server/FileSystem/DirectoryBrowser.cs +++ b/src/Opc.Ua.Server/FileSystem/DirectoryBrowser.cs @@ -44,12 +44,12 @@ public DirectoryBrowser( NodeId referenceType, bool includeSubtypes, BrowseDirection browseDirection, QualifiedName browseName, IEnumerable? additionalReferences, bool internalOnly, - FileSystemNodeManager manager, + IFileSystemHost host, DirectoryObjectState source) : base(context, view, referenceType, includeSubtypes, browseDirection, browseName, additionalReferences, internalOnly) { - m_manager = manager; + m_host = host; m_source = source; m_stage = Stage.Begin; } @@ -64,7 +64,7 @@ public DirectoryBrowser( return reference; } - if (InternalOnly || m_manager == null) + if (InternalOnly || m_host == null) { return null; } @@ -98,7 +98,7 @@ private List LoadEntries() var list = new List(); try { - IAsyncEnumerator enumerator = m_manager.Provider + IAsyncEnumerator enumerator = m_host.Provider .EnumerateAsync(m_source.ProviderPath, CancellationToken.None) .GetAsyncEnumerator(CancellationToken.None); try @@ -160,8 +160,8 @@ private List LoadEntries() private NodeStateReference CreateReference(FileSystemEntry entry) { NodeId targetId = entry.IsDirectory - ? FileSystemNodeId.BuildDirectory(entry.Path, m_manager.NamespaceIndex) - : FileSystemNodeId.BuildFile(entry.Path, m_manager.NamespaceIndex); + ? m_host.BuildDirectoryNodeId(entry.Path) + : m_host.BuildFileNodeId(entry.Path); return new NodeStateReference(ReferenceTypeIds.HasComponent, false, targetId); } @@ -172,7 +172,7 @@ private enum Stage Done } - private readonly FileSystemNodeManager m_manager; + private readonly IFileSystemHost m_host; private readonly DirectoryObjectState m_source; private List? m_pending; private Stage m_stage; diff --git a/src/Opc.Ua.Server/FileSystem/DirectoryObjectState.cs b/src/Opc.Ua.Server/FileSystem/DirectoryObjectState.cs index ee9507fcda..f4c7a488c1 100644 --- a/src/Opc.Ua.Server/FileSystem/DirectoryObjectState.cs +++ b/src/Opc.Ua.Server/FileSystem/DirectoryObjectState.cs @@ -27,9 +27,7 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ -using System; using System.Collections.Generic; -using System.IO; using System.Threading; using System.Threading.Tasks; @@ -53,15 +51,27 @@ public DirectoryObjectState( string providerPath, string displayName, bool isRoot) + : this(context, nodeId, providerPath, displayName, isRoot, host: null) + { + } + + public DirectoryObjectState( + ISystemContext context, + NodeId nodeId, + string providerPath, + string displayName, + bool isRoot, + IFileSystemHost? host) : base(null) { + m_host = host; ProviderPath = providerPath ?? string.Empty; IsRoot = isRoot; TypeDefinitionId = ObjectTypeIds.FileDirectoryType; SymbolicName = ProviderPath; NodeId = nodeId; - BrowseName = new QualifiedName(displayName); + BrowseName = new QualifiedName(displayName, nodeId.NamespaceIndex); DisplayName = new LocalizedText(displayName); Description = LocalizedText.Null; WriteMask = 0; @@ -115,11 +125,17 @@ public override INodeBrowser CreateBrowser( QualifiedName browseName, IEnumerable? additionalReferences, bool internalOnly) { - var manager = context?.SystemHandle as FileSystemNodeManager; + IFileSystemHost? host = ResolveHost(context); + if (host?.UsesVirtualDirectoryBrowsing != true) + { + return base.CreateBrowser(context, view, referenceType, includeSubtypes, + browseDirection, browseName, additionalReferences, internalOnly); + } + var browser = new DirectoryBrowser( context!, view, referenceType, includeSubtypes, browseDirection, browseName, additionalReferences, - internalOnly, manager!, this); + internalOnly, host, this); PopulateBrowser(context!, browser); return browser; } @@ -128,7 +144,8 @@ protected override void PopulateBrowser(ISystemContext context, NodeBrowser brow { base.PopulateBrowser(context, browser); - if (context?.SystemHandle is not FileSystemNodeManager manager) + IFileSystemHost? host = ResolveHost(context); + if (host == null) { return; } @@ -145,7 +162,7 @@ protected override void PopulateBrowser(ISystemContext context, NodeBrowser brow } else { - NodeId parentId = manager.GetParentNodeId(ProviderPath); + NodeId parentId = host.GetParentNodeId(ProviderPath); if (!parentId.IsNull) { browser.Add(ReferenceTypeIds.HasComponent, true, parentId); @@ -158,7 +175,8 @@ private async ValueTask OnCreateDirectoryAsync ISystemContext context, MethodState method, NodeId objectId, string directoryName, CancellationToken cancellationToken) { - if (context?.SystemHandle is not FileSystemNodeManager manager) + IFileSystemHost? host = ResolveHost(context); + if (host == null) { return new CreateDirectoryMethodStateResult { @@ -166,47 +184,17 @@ private async ValueTask OnCreateDirectoryAsync "Node manager unavailable.") }; } - if (string.IsNullOrEmpty(directoryName)) - { - return new CreateDirectoryMethodStateResult - { - ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument, - "Directory name required.") - }; - } - string newPath = manager.CombineProviderPath(ProviderPath, directoryName); - try - { - await manager.Provider.CreateDirectoryAsync(newPath, cancellationToken).ConfigureAwait(false); - return new CreateDirectoryMethodStateResult - { - ServiceResult = ServiceResult.Good, - DirectoryNodeId = FileSystemNodeId.BuildDirectory(newPath, manager.NamespaceIndex) - }; - } - catch (UnauthorizedAccessException ex) - { - return new CreateDirectoryMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, - "Failed to create directory.") - }; - } - catch (IOException ex) - { - return new CreateDirectoryMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadBrowseNameDuplicated, - "Directory or file with same name exists.") - }; - } + + return await FileSystemDirectoryOperations.CreateDirectoryAsync( + host, ProviderPath, directoryName, cancellationToken).ConfigureAwait(false); } private async ValueTask OnCreateFileAsync( ISystemContext context, MethodState method, NodeId objectId, string fileName, bool requestFileOpen, CancellationToken cancellationToken) { - if (context?.SystemHandle is not FileSystemNodeManager manager) + IFileSystemHost? host = ResolveHost(context); + if (host == null) { return new CreateFileMethodStateResult { @@ -214,89 +202,17 @@ private async ValueTask OnCreateFileAsync( "Node manager unavailable.") }; } - if (string.IsNullOrEmpty(fileName)) - { - return new CreateFileMethodStateResult - { - ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument, - "File name required.") - }; - } - - NodeId sessionId = NodeId.Null; - if (requestFileOpen) - { - if (!FileSystemNodeManager.TryGetSessionId( - context, - out NodeId validSessionId, - out ServiceResult sessionResult)) - { - return new CreateFileMethodStateResult - { - ServiceResult = sessionResult - }; - } - - sessionId = validSessionId; - } - string newPath = manager.CombineProviderPath(ProviderPath, fileName); - try - { - if (!requestFileOpen) - { - await manager.Provider.CreateFileAsync(newPath, cancellationToken).ConfigureAwait(false); - return new CreateFileMethodStateResult - { - ServiceResult = ServiceResult.Good, - FileNodeId = FileSystemNodeId.BuildFile(newPath, manager.NamespaceIndex), - FileHandle = 0u - }; - } - - NodeId fileNodeId = FileSystemNodeId.BuildFile(newPath, manager.NamespaceIndex); - FileHandle? handle = manager.GetOrCreateHandle(fileNodeId, newPath); - if (handle == null) - { - return new CreateFileMethodStateResult - { - ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState, - "Failed to obtain file handle.") - }; - } - // Open with write + erase to mirror the spec's - // "create + open for write" semantics. - ServiceResult openResult = handle.Open(sessionId, 0x6, out uint fileHandle); - return new CreateFileMethodStateResult - { - ServiceResult = openResult, - FileNodeId = fileNodeId, - FileHandle = fileHandle - }; - } - catch (UnauthorizedAccessException ex) - { - return new CreateFileMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, - "Failed to create file.") - }; - } - catch (IOException ex) - { - return new CreateFileMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadBrowseNameDuplicated, - "Directory or file with same name exists.") - }; - } + return await FileSystemDirectoryOperations.CreateFileAsync( + host, context, ProviderPath, fileName, requestFileOpen, cancellationToken).ConfigureAwait(false); } private async ValueTask OnDeleteFileSystemObjectAsync( ISystemContext context, MethodState method, NodeId objectId, NodeId objectToDelete, CancellationToken cancellationToken) { - if (context?.SystemHandle is not FileSystemNodeManager manager) + IFileSystemHost? host = ResolveHost(context); + if (host == null) { return new DeleteFileMethodStateResult { @@ -304,60 +220,9 @@ private async ValueTask OnDeleteFileSystemObjectAsy "Node manager unavailable.") }; } - if (!FileSystemNodeId.TryParse(objectToDelete, out FileSystemNodeId parsed)) - { - return new DeleteFileMethodStateResult - { - ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState, - "Not a file-system object.") - }; - } - if (parsed.RootType == FileSystemNodeId.Root) - { - return new DeleteFileMethodStateResult - { - ServiceResult = ServiceResult.Create(StatusCodes.BadUserAccessDenied, - "Cannot delete the file-system root.") - }; - } - try - { - await manager.Provider.DeleteAsync(parsed.ProviderPath, cancellationToken).ConfigureAwait(false); - manager.ForgetHandle(objectToDelete); - return new DeleteFileMethodStateResult { ServiceResult = ServiceResult.Good }; - } - catch (FileNotFoundException ex) - { - return new DeleteFileMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound, - "File-system object not found.") - }; - } - catch (DirectoryNotFoundException ex) - { - return new DeleteFileMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound, - "File-system object not found.") - }; - } - catch (UnauthorizedAccessException ex) - { - return new DeleteFileMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, - "Failed to delete file-system object.") - }; - } - catch (IOException ex) - { - return new DeleteFileMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, - "Failed to delete file-system object.") - }; - } + + return await FileSystemDirectoryOperations.DeleteAsync( + host, objectToDelete, cancellationToken).ConfigureAwait(false); } private async ValueTask OnMoveOrCopyAsync( @@ -365,7 +230,8 @@ private async ValueTask OnMoveOrCopyAsync( NodeId objectToMoveOrCopy, NodeId targetDirectory, bool createCopy, string newName, CancellationToken cancellationToken) { - if (context?.SystemHandle is not FileSystemNodeManager manager) + IFileSystemHost? host = ResolveHost(context); + if (host == null) { return new MoveOrCopyMethodStateResult { @@ -373,94 +239,37 @@ private async ValueTask OnMoveOrCopyAsync( "Node manager unavailable.") }; } - if (!FileSystemNodeId.TryParse(objectToMoveOrCopy, out FileSystemNodeId source) || - source.RootType == FileSystemNodeId.Root) - { - return new MoveOrCopyMethodStateResult - { - ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument, - "Source is not a directory or file.") - }; - } - if (!FileSystemNodeId.TryParse(targetDirectory, out FileSystemNodeId target) || - target.RootType == FileSystemNodeId.File) - { - return new MoveOrCopyMethodStateResult - { - ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument, - "Target is not a directory.") - }; - } - - string sourceName = ProviderPathName(source.ProviderPath); - string finalName = !string.IsNullOrEmpty(newName) ? newName : sourceName; - string targetPath = manager.CombineProviderPath(target.ProviderPath, finalName); - try - { - if (createCopy) - { - await manager.Provider.CopyAsync(source.ProviderPath, targetPath, cancellationToken) - .ConfigureAwait(false); - } - else - { - await manager.Provider.MoveAsync(source.ProviderPath, targetPath, cancellationToken) - .ConfigureAwait(false); - manager.ForgetHandle(objectToMoveOrCopy); - } + return await FileSystemDirectoryOperations.MoveOrCopyAsync( + host, objectToMoveOrCopy, targetDirectory, createCopy, newName, cancellationToken) + .ConfigureAwait(false); + } - NodeId newNodeId = source.RootType == FileSystemNodeId.File - ? FileSystemNodeId.BuildFile(targetPath, manager.NamespaceIndex) - : FileSystemNodeId.BuildDirectory(targetPath, manager.NamespaceIndex); - return new MoveOrCopyMethodStateResult - { - ServiceResult = ServiceResult.Good, - NewNodeId = newNodeId - }; - } - catch (FileNotFoundException ex) + internal void DetachCallbacks() + { + if (DeleteFileSystemObject != null) { - return new MoveOrCopyMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound, - "Source not found.") - }; + DeleteFileSystemObject.OnCallAsync = null; } - catch (DirectoryNotFoundException ex) + if (CreateFile != null) { - return new MoveOrCopyMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound, - "Source not found.") - }; + CreateFile.OnCallAsync = null; } - catch (UnauthorizedAccessException ex) + if (CreateDirectory != null) { - return new MoveOrCopyMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, - "Failed to move or copy.") - }; + CreateDirectory.OnCallAsync = null; } - catch (IOException ex) + if (MoveOrCopy != null) { - return new MoveOrCopyMethodStateResult - { - ServiceResult = ServiceResult.Create(ex, StatusCodes.BadBrowseNameDuplicated, - "Failed to move or copy.") - }; + MoveOrCopy.OnCallAsync = null; } } - private static string ProviderPathName(string providerPath) + private IFileSystemHost? ResolveHost(ISystemContext context) { - if (string.IsNullOrEmpty(providerPath)) - { - return string.Empty; - } - int slash = providerPath.LastIndexOf('/'); - return slash < 0 ? providerPath : providerPath[(slash + 1)..]; + return m_host ?? context?.SystemHandle as IFileSystemHost; } + + private readonly IFileSystemHost? m_host; } } diff --git a/src/Opc.Ua.Server/FileSystem/FileDirectoryBinder.cs b/src/Opc.Ua.Server/FileSystem/FileDirectoryBinder.cs new file mode 100644 index 0000000000..024269fd6f --- /dev/null +++ b/src/Opc.Ua.Server/FileSystem/FileDirectoryBinder.cs @@ -0,0 +1,629 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.Server.FileSystem +{ + /// + /// Options that control how a bound FileDirectoryType materialises provider entries. + /// + public sealed class FileDirectoryBindingOptions + { + /// + /// Gets or sets a value indicating whether CreateFile and CreateDirectory are exposed as writable operations. + /// + public bool AllowCreate { get; set; } = true; + + /// + /// Gets or sets a value indicating whether DeleteFileSystemObject is exposed as a writable operation. + /// + public bool AllowDelete { get; set; } = true; + + /// + /// Gets or sets a value indicating whether MoveOrCopy is exposed as a writable operation. + /// + public bool AllowMoveOrCopy { get; set; } = true; + + /// + /// Gets or sets the maximum number of entries materialised per directory. + /// + public int MaxEntries { get; set; } = 1024; + + /// + /// Gets or sets the maximum directory nesting materialised below the bound root. + /// + public int MaxDepth { get; set; } = 8; + } + + /// + /// Represents an active binding between a FileDirectoryType node and an IFileSystemProvider. + /// + public interface IFileDirectoryBinding : IAsyncDisposable + { + /// + /// Gets the bound directory node. + /// + FileDirectoryState Directory { get; } + + /// + /// Gets the provider that backs the directory node. + /// + IFileSystemProvider Provider { get; } + + /// + /// Re-reads the provider and reconciles materialised child nodes. + /// + ValueTask RefreshAsync(CancellationToken cancellationToken = default); + } + + /// + /// Creates bindings that back existing FileDirectoryType nodes with file-system providers. + /// + public interface IFileDirectoryBinder + { + /// + /// Binds a FileDirectoryType node to a provider and materialises its current contents. + /// + ValueTask BindAsync( + FileDirectoryState directory, + IFileSystemProvider provider, + ISystemContext context, + FileDirectoryBindingOptions? options = null, + Func? registerNode = null, + CancellationToken cancellationToken = default); + } + + /// + /// Default FileDirectoryType binder implementation. + /// + public sealed class FileDirectoryBinder : IFileDirectoryBinder + { + /// + /// Initializes a new instance of the class. + /// + public FileDirectoryBinder(ITelemetryContext telemetry) + { + if (telemetry == null) + { + throw new ArgumentNullException(nameof(telemetry)); + } + } + + /// + public async ValueTask BindAsync( + FileDirectoryState directory, + IFileSystemProvider provider, + ISystemContext context, + FileDirectoryBindingOptions? options = null, + Func? registerNode = null, + CancellationToken cancellationToken = default) + { + if (directory == null) + { + throw new ArgumentNullException(nameof(directory)); + } + if (provider == null) + { + throw new ArgumentNullException(nameof(provider)); + } + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var binding = new FileDirectoryBinding(directory, provider, context, + options ?? new FileDirectoryBindingOptions(), registerNode); + await binding.InitializeAsync(cancellationToken).ConfigureAwait(false); + return binding; + } + + private sealed class FileDirectoryBinding : IFileDirectoryBinding, IFileSystemHost + { + public FileDirectoryBinding( + FileDirectoryState directory, + IFileSystemProvider provider, + ISystemContext context, + FileDirectoryBindingOptions options, + Func? registerNode) + { + Directory = directory; + Provider = provider; + m_context = context; + m_options = ValidateOptions(options); + m_registerNode = registerNode; + m_nodeIdPrefix = "FileDirectoryBinding:" + directory.NodeId; + } + + public FileDirectoryState Directory { get; } + + public IFileSystemProvider Provider { get; } + + public bool AllowCreate => m_options.AllowCreate; + + public bool AllowDelete => m_options.AllowDelete; + + public bool AllowMoveOrCopy => m_options.AllowMoveOrCopy; + + public bool UsesVirtualDirectoryBrowsing => false; + + public async ValueTask RefreshAsync(CancellationToken cancellationToken = default) + { + await m_gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var seen = new HashSet(StringComparer.Ordinal); + await ReconcileDirectoryAsync(Directory, string.Empty, 0, seen, cancellationToken) + .ConfigureAwait(false); + RemoveStaleNodes(seen); + } + finally + { + m_gate.Release(); + } + } + + public ValueTask DisposeAsync() + { + if (m_disposed) + { + return default; + } + + m_disposed = true; + DetachDirectoryCallbacks(Directory); + foreach (MaterializedNode entry in m_nodesByPath.Values) + { + DetachCallbacks(entry.Node); + } + foreach (FileHandle handle in m_handles.Values) + { + handle.Dispose(); + } + foreach (MaterializedNode entry in m_nodesByPath.Values) + { + entry.Node.Parent?.RemoveChild(entry.Node); + } + m_nodesByPath.Clear(); + m_nodesById.Clear(); + m_handles.Clear(); + m_gate.Dispose(); + return default; + } + + public NodeId BuildDirectoryNodeId(string providerPath) + { + return string.IsNullOrEmpty(providerPath) + ? Directory.NodeId + : CreateMaterializedNodeId("dir", providerPath); + } + + public NodeId BuildFileNodeId(string providerPath) + { + return CreateMaterializedNodeId("file", providerPath); + } + + public string CombineProviderPath(string parent, string name) + { + if (string.IsNullOrEmpty(parent)) + { + return name; + } + return parent.TrimEnd('/') + "/" + name; + } + + public NodeId GetParentNodeId(string providerPath) + { + if (string.IsNullOrEmpty(providerPath)) + { + return NodeId.Null; + } + int slash = providerPath.LastIndexOf('/'); + string parent = slash < 0 ? string.Empty : providerPath[..slash]; + return string.IsNullOrEmpty(parent) ? Directory.NodeId : BuildDirectoryNodeId(parent); + } + + public FileHandle? GetOrCreateHandle(NodeId nodeId, string providerPath) + { + lock (m_lock) + { + if (m_handles.TryGetValue(nodeId, out FileHandle? handle)) + { + return handle; + } + + handle = new FileHandle(Provider, providerPath); + m_handles.Add(nodeId, handle); + return handle; + } + } + + public void ForgetHandle(NodeId nodeId) + { + lock (m_lock) + { + if (m_handles.TryGetValue(nodeId, out FileHandle? handle)) + { + handle.Dispose(); + m_handles.Remove(nodeId); + } + } + } + + public async ValueTask OnProviderChangedAsync(CancellationToken cancellationToken) + { + await RefreshAsync(cancellationToken).ConfigureAwait(false); + } + + public bool TryGetProviderPath( + NodeId nodeId, + out string providerPath, + out bool isDirectory, + out bool isRoot) + { + if (nodeId == Directory.NodeId) + { + providerPath = string.Empty; + isDirectory = true; + isRoot = true; + return true; + } + if (m_nodesById.TryGetValue(nodeId, out MaterializedNode? entry)) + { + providerPath = entry.ProviderPath; + isDirectory = entry.IsDirectory; + isRoot = false; + return true; + } + + providerPath = string.Empty; + isDirectory = false; + isRoot = false; + return false; + } + + public async ValueTask InitializeAsync(CancellationToken cancellationToken) + { + WireDirectoryCallbacks(Directory, providerPath: string.Empty); + m_initializing = true; + try + { + await RefreshAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + m_initializing = false; + } + } + + private async ValueTask ReconcileDirectoryAsync( + FileDirectoryState parent, + string providerPath, + int depth, + HashSet seen, + CancellationToken cancellationToken) + { + List entries = []; + await foreach (FileSystemEntry entry in Provider.EnumerateAsync(providerPath, cancellationToken) + .ConfigureAwait(false)) + { + if (entries.Count >= m_options.MaxEntries) + { + throw new ServiceResultException(StatusCodes.BadEncodingLimitsExceeded, + "The directory contains more entries than the configured binding limit."); + } + entries.Add(entry); + } + + foreach (FileSystemEntry entry in entries) + { + if (entry.IsDirectory) + { + if (depth >= m_options.MaxDepth) + { + continue; + } + + FileDirectoryState child = await GetOrCreateDirectoryAsync(parent, entry, cancellationToken) + .ConfigureAwait(false); + seen.Add(entry.Path); + if (depth + 1 < m_options.MaxDepth) + { + await ReconcileDirectoryAsync(child, entry.Path, depth + 1, seen, cancellationToken) + .ConfigureAwait(false); + } + } + else + { + await GetOrCreateFileAsync(parent, entry, cancellationToken).ConfigureAwait(false); + seen.Add(entry.Path); + } + } + } + + private async ValueTask GetOrCreateDirectoryAsync( + FileDirectoryState parent, + FileSystemEntry entry, + CancellationToken cancellationToken) + { + if (m_nodesByPath.TryGetValue(entry.Path, out MaterializedNode? existing)) + { + if (existing.Node is FileDirectoryState directory) + { + return directory; + } + RemoveNode(existing); + } + + var node = new DirectoryObjectState( + m_context, + BuildDirectoryNodeId(entry.Path), + entry.Path, + entry.Name, + isRoot: false, + this); + parent.AddChild(node); + AddMaterializedNode(entry.Path, node, isDirectory: true); + await RegisterNodeAsync(node, cancellationToken).ConfigureAwait(false); + return node; + } + + private async ValueTask GetOrCreateFileAsync( + FileDirectoryState parent, + FileSystemEntry entry, + CancellationToken cancellationToken) + { + if (m_nodesByPath.TryGetValue(entry.Path, out MaterializedNode? existing)) + { + if (existing.Node is FileState file) + { + return file; + } + RemoveNode(existing); + } + + var node = new FileObjectState(m_context, BuildFileNodeId(entry.Path), entry.Path, entry.Name, this); + parent.AddChild(node); + AddMaterializedNode(entry.Path, node, isDirectory: false); + await RegisterNodeAsync(node, cancellationToken).ConfigureAwait(false); + return node; + } + + private async ValueTask RegisterNodeAsync(NodeState node, CancellationToken cancellationToken) + { + if (!m_initializing && m_registerNode != null) + { + await m_registerNode(node, cancellationToken).ConfigureAwait(false); + } + } + + private void RemoveStaleNodes(HashSet seen) + { + List stale = []; + foreach (MaterializedNode entry in m_nodesByPath.Values) + { + if (!seen.Contains(entry.ProviderPath)) + { + stale.Add(entry); + } + } + stale.Sort(static (left, right) => right.ProviderPath.Length.CompareTo(left.ProviderPath.Length)); + foreach (MaterializedNode entry in stale) + { + RemoveNode(entry); + } + } + + private void RemoveNode(MaterializedNode entry) + { + List descendants = []; + string prefix = entry.ProviderPath + "/"; + foreach (MaterializedNode candidate in m_nodesByPath.Values) + { + if (candidate.ProviderPath.StartsWith(prefix, StringComparison.Ordinal)) + { + descendants.Add(candidate); + } + } + descendants.Sort(static (left, right) => right.ProviderPath.Length.CompareTo(left.ProviderPath.Length)); + foreach (MaterializedNode descendant in descendants) + { + RemoveSingleNode(descendant); + } + RemoveSingleNode(entry); + } + + private void RemoveSingleNode(MaterializedNode entry) + { + DetachCallbacks(entry.Node); + ForgetHandle(entry.Node.NodeId); + entry.Node.Parent?.RemoveChild(entry.Node); + m_nodesByPath.Remove(entry.ProviderPath); + m_nodesById.Remove(entry.Node.NodeId); + } + + private void AddMaterializedNode(string providerPath, BaseInstanceState node, bool isDirectory) + { + var entry = new MaterializedNode(providerPath, node, isDirectory); + m_nodesByPath[providerPath] = entry; + m_nodesById[node.NodeId] = entry; + } + + private void WireDirectoryCallbacks(FileDirectoryState directory, string providerPath) + { + EnsureDirectoryMethods(directory); + directory.DeleteFileSystemObject!.OnCallAsync = (context, method, objectId, objectToDelete, ct) => + FileSystemDirectoryOperations.DeleteAsync(this, objectToDelete, ct); + directory.CreateFile!.OnCallAsync = (context, method, objectId, fileName, requestFileOpen, ct) => + FileSystemDirectoryOperations.CreateFileAsync( + this, context, providerPath, fileName, requestFileOpen, ct); + directory.CreateDirectory!.OnCallAsync = (context, method, objectId, directoryName, ct) => + FileSystemDirectoryOperations.CreateDirectoryAsync(this, providerPath, directoryName, ct); + directory.MoveOrCopy!.OnCallAsync = ( + context, + method, + objectId, + objectToMoveOrCopy, + targetDirectory, + createCopy, + newName, + ct) => FileSystemDirectoryOperations.MoveOrCopyAsync( + this, objectToMoveOrCopy, targetDirectory, createCopy, newName, ct); + } + + private void DetachDirectoryCallbacks(FileDirectoryState directory) + { + if (directory.DeleteFileSystemObject != null) + { + directory.DeleteFileSystemObject.OnCallAsync = null; + } + if (directory.CreateFile != null) + { + directory.CreateFile.OnCallAsync = null; + } + if (directory.CreateDirectory != null) + { + directory.CreateDirectory.OnCallAsync = null; + } + if (directory.MoveOrCopy != null) + { + directory.MoveOrCopy.OnCallAsync = null; + } + } + + private void EnsureDirectoryMethods(FileDirectoryState directory) + { + if (directory.DeleteFileSystemObject == null) + { + directory.DeleteFileSystemObject = new DeleteFileMethodState(directory); + directory.DeleteFileSystemObject.Create( + m_context, + MethodIds.FileDirectoryType_DeleteFileSystemObject, + new QualifiedName(BrowseNames.DeleteFileSystemObject), + new LocalizedText(BrowseNames.DeleteFileSystemObject), false); + } + directory.DeleteFileSystemObject.Executable = true; + directory.DeleteFileSystemObject.UserExecutable = true; + + if (directory.CreateFile == null) + { + directory.CreateFile = new CreateFileMethodState(directory); + directory.CreateFile.Create(m_context, MethodIds.FileDirectoryType_CreateFile, + new QualifiedName(BrowseNames.CreateFile), + new LocalizedText(BrowseNames.CreateFile), false); + } + directory.CreateFile.Executable = true; + directory.CreateFile.UserExecutable = true; + + if (directory.CreateDirectory == null) + { + directory.CreateDirectory = new CreateDirectoryMethodState(directory); + directory.CreateDirectory.Create(m_context, MethodIds.FileDirectoryType_CreateDirectory, + new QualifiedName(BrowseNames.CreateDirectory), + new LocalizedText(BrowseNames.CreateDirectory), false); + } + directory.CreateDirectory.Executable = true; + directory.CreateDirectory.UserExecutable = true; + + if (directory.MoveOrCopy == null) + { + directory.MoveOrCopy = new MoveOrCopyMethodState(directory); + directory.MoveOrCopy.Create(m_context, MethodIds.FileDirectoryType_MoveOrCopy, + new QualifiedName(BrowseNames.MoveOrCopy), + new LocalizedText(BrowseNames.MoveOrCopy), false); + } + directory.MoveOrCopy.Executable = true; + directory.MoveOrCopy.UserExecutable = true; + } + + private NodeId CreateMaterializedNodeId(string kind, string providerPath) + { + string escapedPath = Uri.EscapeDataString(providerPath); + return new NodeId(m_nodeIdPrefix + "/" + kind + "/" + escapedPath, Directory.NodeId.NamespaceIndex); + } + + private static void DetachCallbacks(NodeState node) + { + if (node is DirectoryObjectState directory) + { + directory.DetachCallbacks(); + } + else if (node is FileObjectState file) + { + file.DetachCallbacks(); + } + } + + private static FileDirectoryBindingOptions ValidateOptions(FileDirectoryBindingOptions options) + { + if (options.MaxEntries < 0) + { + throw new ArgumentOutOfRangeException(nameof(options), "MaxEntries must be non-negative."); + } + if (options.MaxDepth < 0) + { + throw new ArgumentOutOfRangeException(nameof(options), "MaxDepth must be non-negative."); + } + + return options; + } + + private readonly SemaphoreSlim m_gate = new(1, 1); + private readonly Dictionary m_handles = []; + private readonly Dictionary m_nodesById = []; + private readonly Dictionary m_nodesByPath = new(StringComparer.Ordinal); + private readonly ISystemContext m_context; + private readonly FileDirectoryBindingOptions m_options; + private readonly Func? m_registerNode; + private readonly Lock m_lock = new(); + private readonly string m_nodeIdPrefix; + private bool m_disposed; + private bool m_initializing; + + private sealed class MaterializedNode + { + public MaterializedNode(string providerPath, BaseInstanceState node, bool isDirectory) + { + ProviderPath = providerPath; + Node = node; + IsDirectory = isDirectory; + } + + public string ProviderPath { get; } + + public BaseInstanceState Node { get; } + + public bool IsDirectory { get; } + } + } + } +} diff --git a/src/Opc.Ua.Server/FileSystem/FileObjectState.cs b/src/Opc.Ua.Server/FileSystem/FileObjectState.cs index 84feb69bdc..ce3b30fe55 100644 --- a/src/Opc.Ua.Server/FileSystem/FileObjectState.cs +++ b/src/Opc.Ua.Server/FileSystem/FileObjectState.cs @@ -50,14 +50,25 @@ public FileObjectState( NodeId nodeId, string providerPath, string displayName) + : this(context, nodeId, providerPath, displayName, host: null) + { + } + + public FileObjectState( + ISystemContext context, + NodeId nodeId, + string providerPath, + string displayName, + IFileSystemHost? host) : base(null) { + m_host = host; ProviderPath = providerPath; TypeDefinitionId = ObjectTypeIds.FileType; SymbolicName = providerPath; NodeId = nodeId; - BrowseName = new QualifiedName(displayName); + BrowseName = new QualifiedName(displayName, nodeId.NamespaceIndex); DisplayName = new LocalizedText(displayName); Description = LocalizedText.Null; WriteMask = 0; @@ -404,12 +415,12 @@ protected override void PopulateBrowser(ISystemContext context, NodeBrowser brow } // Reverse reference to the parent directory. - FileSystemNodeManager? manager = ResolveManager(context); - if (manager == null) + IFileSystemHost? host = ResolveHost(context); + if (host == null) { return; } - NodeId parentId = manager.GetParentNodeId(ProviderPath); + NodeId parentId = host.GetParentNodeId(ProviderPath); if (!parentId.IsNull) { browser.Add(ReferenceTypeIds.HasComponent, true, parentId); @@ -421,8 +432,8 @@ private bool TryGetHandle( [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out FileHandle? handle, out ServiceResult result) { - FileSystemNodeManager? manager = ResolveManager(context); - if (manager == null) + IFileSystemHost? host = ResolveHost(context); + if (host == null) { handle = null; result = ServiceResult.Create( @@ -431,7 +442,7 @@ private bool TryGetHandle( return false; } - handle = manager.GetOrCreateHandle(NodeId, ProviderPath); + handle = host.GetOrCreateHandle(NodeId, ProviderPath); if (handle == null) { result = ServiceResult.Create( @@ -443,9 +454,63 @@ private bool TryGetHandle( return true; } - private static FileSystemNodeManager? ResolveManager(ISystemContext context) + internal void DetachCallbacks() { - return context?.SystemHandle as FileSystemNodeManager; + if (OpenCount != null) + { + OpenCount.OnReadValue -= OnOpenCount; + } + if (Writable != null) + { + Writable.OnReadValue -= OnWritable; + } + if (UserWritable != null) + { + UserWritable.OnReadValue -= OnWritable; + } + if (Size != null) + { + Size.OnReadValue -= OnSize; + } + if (MimeType != null) + { + MimeType.OnReadValue -= OnMimeType; + } + if (LastModifiedTime != null) + { + LastModifiedTime.OnReadValue -= OnLastModifiedTime; + } + if (Open != null) + { + Open.OnCall = null; + } + if (Write != null) + { + Write.OnCall = null; + } + if (Read != null) + { + Read.OnCall = null; + } + if (Close != null) + { + Close.OnCall = null; + } + if (GetPosition != null) + { + GetPosition.OnCall = null; + } + if (SetPosition != null) + { + SetPosition.OnCall = null; + } } + + private IFileSystemHost? ResolveHost(ISystemContext context) + { + return m_host ?? context?.SystemHandle as IFileSystemHost; + } + + private readonly IFileSystemHost? m_host; } } diff --git a/src/Opc.Ua.Server/FileSystem/FileSystemDirectoryOperations.cs b/src/Opc.Ua.Server/FileSystem/FileSystemDirectoryOperations.cs new file mode 100644 index 0000000000..fa767efb2f --- /dev/null +++ b/src/Opc.Ua.Server/FileSystem/FileSystemDirectoryOperations.cs @@ -0,0 +1,451 @@ +/* ======================================================================== + * 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.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Server.FileSystem +{ + /// + /// Shared implementation of FileDirectoryType method bodies. + /// + internal static class FileSystemDirectoryOperations + { + public static async ValueTask CreateDirectoryAsync( + IFileSystemHost host, + string providerPath, + string directoryName, + CancellationToken cancellationToken) + { + if (!CanCreate(host, out ServiceResult accessResult)) + { + return new CreateDirectoryMethodStateResult { ServiceResult = accessResult }; + } + if (string.IsNullOrEmpty(directoryName)) + { + return new CreateDirectoryMethodStateResult + { + ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument, "Directory name required.") + }; + } + if (!IsValidEntryName(directoryName, out ServiceResult directoryNameResult)) + { + return new CreateDirectoryMethodStateResult { ServiceResult = directoryNameResult }; + } + + string newPath = host.CombineProviderPath(providerPath, directoryName); + try + { + await host.Provider.CreateDirectoryAsync(newPath, cancellationToken).ConfigureAwait(false); + await host.OnProviderChangedAsync(cancellationToken).ConfigureAwait(false); + return new CreateDirectoryMethodStateResult + { + ServiceResult = ServiceResult.Good, + DirectoryNodeId = host.BuildDirectoryNodeId(newPath) + }; + } + catch (UnauthorizedAccessException ex) + { + return new CreateDirectoryMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, + "Failed to create directory.") + }; + } + catch (NotSupportedException ex) + { + return new CreateDirectoryMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotSupported, + "Directory creation is not supported.") + }; + } + catch (IOException ex) + { + return new CreateDirectoryMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadBrowseNameDuplicated, + "Directory or file with same name exists.") + }; + } + } + + public static async ValueTask CreateFileAsync( + IFileSystemHost host, + ISystemContext context, + string providerPath, + string fileName, + bool requestFileOpen, + CancellationToken cancellationToken) + { + if (!CanCreate(host, out ServiceResult accessResult)) + { + return new CreateFileMethodStateResult { ServiceResult = accessResult }; + } + if (string.IsNullOrEmpty(fileName)) + { + return new CreateFileMethodStateResult + { + ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument, "File name required.") + }; + } + if (!IsValidEntryName(fileName, out ServiceResult fileNameResult)) + { + return new CreateFileMethodStateResult { ServiceResult = fileNameResult }; + } + + NodeId sessionId = NodeId.Null; + if (requestFileOpen && + !FileSystemNodeManager.TryGetSessionId(context, out sessionId, out ServiceResult sessionResult)) + { + return new CreateFileMethodStateResult { ServiceResult = sessionResult }; + } + + string newPath = host.CombineProviderPath(providerPath, fileName); + NodeId fileNodeId = host.BuildFileNodeId(newPath); + try + { + await host.Provider.CreateFileAsync(newPath, cancellationToken).ConfigureAwait(false); + await host.OnProviderChangedAsync(cancellationToken).ConfigureAwait(false); + if (!requestFileOpen) + { + return new CreateFileMethodStateResult + { + ServiceResult = ServiceResult.Good, + FileNodeId = fileNodeId, + FileHandle = 0u + }; + } + + FileHandle? handle = host.GetOrCreateHandle(fileNodeId, newPath); + if (handle == null) + { + return new CreateFileMethodStateResult + { + ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState, + "Failed to obtain file handle.") + }; + } + + ServiceResult openResult = handle.Open(sessionId, 0x6, out uint fileHandle); + return new CreateFileMethodStateResult + { + ServiceResult = openResult, + FileNodeId = fileNodeId, + FileHandle = fileHandle + }; + } + catch (UnauthorizedAccessException ex) + { + return new CreateFileMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, + "Failed to create file.") + }; + } + catch (NotSupportedException ex) + { + return new CreateFileMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotSupported, + "File creation is not supported.") + }; + } + catch (IOException ex) + { + return new CreateFileMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadBrowseNameDuplicated, + "Directory or file with same name exists.") + }; + } + } + + public static async ValueTask DeleteAsync( + IFileSystemHost host, + NodeId objectToDelete, + CancellationToken cancellationToken) + { + if (!host.Provider.IsWritable || !host.AllowDelete) + { + return new DeleteFileMethodStateResult + { + ServiceResult = ServiceResult.Create(StatusCodes.BadUserAccessDenied, + "Deleting file-system objects is not allowed.") + }; + } + if (!host.TryGetProviderPath(objectToDelete, out string providerPath, out _, out bool isRoot)) + { + return new DeleteFileMethodStateResult + { + ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState, + "Not a file-system object.") + }; + } + if (isRoot) + { + return new DeleteFileMethodStateResult + { + ServiceResult = ServiceResult.Create(StatusCodes.BadUserAccessDenied, + "Cannot delete the file-system root.") + }; + } + + try + { + await host.Provider.DeleteAsync(providerPath, cancellationToken).ConfigureAwait(false); + host.ForgetHandle(objectToDelete); + await host.OnProviderChangedAsync(cancellationToken).ConfigureAwait(false); + return new DeleteFileMethodStateResult { ServiceResult = ServiceResult.Good }; + } + catch (FileNotFoundException ex) + { + return CreateNotFoundDeleteResult(ex); + } + catch (DirectoryNotFoundException ex) + { + return CreateNotFoundDeleteResult(ex); + } + catch (UnauthorizedAccessException ex) + { + return new DeleteFileMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, + "Failed to delete file-system object.") + }; + } + catch (NotSupportedException ex) + { + return new DeleteFileMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotSupported, + "Deleting file-system objects is not supported.") + }; + } + catch (IOException ex) + { + return new DeleteFileMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, + "Failed to delete file-system object.") + }; + } + } + + public static async ValueTask MoveOrCopyAsync( + IFileSystemHost host, + NodeId objectToMoveOrCopy, + NodeId targetDirectory, + bool createCopy, + string newName, + CancellationToken cancellationToken) + { + if (!host.Provider.IsWritable || !host.AllowMoveOrCopy) + { + return new MoveOrCopyMethodStateResult + { + ServiceResult = ServiceResult.Create(StatusCodes.BadUserAccessDenied, + "Moving or copying file-system objects is not allowed.") + }; + } + if (!host.TryGetProviderPath(objectToMoveOrCopy, out string sourcePath, out bool sourceIsDirectory, + out bool sourceIsRoot) || + sourceIsRoot) + { + return new MoveOrCopyMethodStateResult + { + ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument, + "Source is not a directory or file.") + }; + } + if (!host.TryGetProviderPath(targetDirectory, out string targetDirectoryPath, out bool targetIsDirectory, + out _) || + !targetIsDirectory) + { + return new MoveOrCopyMethodStateResult + { + ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument, + "Target is not a directory.") + }; + } + + string sourceName = ProviderPathName(sourcePath); + string finalName = !string.IsNullOrEmpty(newName) ? newName : sourceName; + if (!IsValidEntryName(finalName, out ServiceResult newNameResult)) + { + return new MoveOrCopyMethodStateResult { ServiceResult = newNameResult }; + } + string targetPath = host.CombineProviderPath(targetDirectoryPath, finalName); + + try + { + if (createCopy) + { + await host.Provider.CopyAsync(sourcePath, targetPath, cancellationToken).ConfigureAwait(false); + } + else + { + await host.Provider.MoveAsync(sourcePath, targetPath, cancellationToken).ConfigureAwait(false); + host.ForgetHandle(objectToMoveOrCopy); + } + + await host.OnProviderChangedAsync(cancellationToken).ConfigureAwait(false); + NodeId newNodeId = sourceIsDirectory + ? host.BuildDirectoryNodeId(targetPath) + : host.BuildFileNodeId(targetPath); + return new MoveOrCopyMethodStateResult + { + ServiceResult = ServiceResult.Good, + NewNodeId = newNodeId + }; + } + catch (FileNotFoundException ex) + { + return CreateNotFoundMoveOrCopyResult(ex); + } + catch (DirectoryNotFoundException ex) + { + return CreateNotFoundMoveOrCopyResult(ex); + } + catch (UnauthorizedAccessException ex) + { + return new MoveOrCopyMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied, + "Failed to move or copy.") + }; + } + catch (NotSupportedException ex) + { + return new MoveOrCopyMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotSupported, + "Moving or copying is not supported.") + }; + } + catch (IOException ex) + { + return new MoveOrCopyMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadBrowseNameDuplicated, + "Failed to move or copy.") + }; + } + } + + private static bool CanCreate(IFileSystemHost host, out ServiceResult result) + { + if (!host.Provider.IsWritable || !host.AllowCreate) + { + result = ServiceResult.Create(StatusCodes.BadUserAccessDenied, + "Creating file-system objects is not allowed."); + return false; + } + + result = ServiceResult.Good; + return true; + } + + /// + /// Rejects a client-supplied entry name that is anything other than a + /// single path segment. + /// + /// + /// + /// Names arrive straight from a remote caller and are combined into a + /// provider path, so the stack refuses separators, drive/stream + /// qualifiers and the relative aliases here rather than relying on + /// every to re-derive the rule. A + /// provider that follows the documented contract and splits the + /// forward-slash path to reject ".." segments would otherwise + /// still be escaped on Windows by a name such as + /// "..\..\Windows\System32\evil", which contains no + /// slash-delimited ".." segment yet is resolved as traversal by + /// . + /// + /// + /// This is deliberately a defence in depth: providers remain + /// responsible for confining resolved paths to their own root. + /// + /// + /// The client-supplied entry name. + /// The failure to report when invalid. + /// true when the name is a safe single segment. + private static bool IsValidEntryName(string name, out ServiceResult result) + { + if (string.IsNullOrWhiteSpace(name) || + name.IndexOfAny(kEntryNameSeparators) >= 0 || + name == "." || + name == "..") + { + result = ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "A file-system entry name must be a single segment and may not " + + "contain a path separator, a drive or stream qualifier, or be " + + "'.' or '..'."); + return false; + } + + result = ServiceResult.Good; + return true; + } + + private static readonly char[] kEntryNameSeparators = ['/', '\\', ':']; + + private static DeleteFileMethodStateResult CreateNotFoundDeleteResult(Exception ex) + { + return new DeleteFileMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound, + "File-system object not found.") + }; + } + + private static MoveOrCopyMethodStateResult CreateNotFoundMoveOrCopyResult(Exception ex) + { + return new MoveOrCopyMethodStateResult + { + ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound, + "Source not found.") + }; + } + + private static string ProviderPathName(string providerPath) + { + if (string.IsNullOrEmpty(providerPath)) + { + return string.Empty; + } + int slash = providerPath.LastIndexOf('/'); + return slash < 0 ? providerPath : providerPath[(slash + 1)..]; + } + } +} diff --git a/src/Opc.Ua.Server/FileSystem/FileSystemNodeManager.cs b/src/Opc.Ua.Server/FileSystem/FileSystemNodeManager.cs index 305314cbc2..ec473a0ea3 100644 --- a/src/Opc.Ua.Server/FileSystem/FileSystemNodeManager.cs +++ b/src/Opc.Ua.Server/FileSystem/FileSystemNodeManager.cs @@ -55,7 +55,7 @@ namespace Opc.Ua.Server.FileSystem /// under i=16314. /// /// - public sealed class FileSystemNodeManager : AsyncCustomNodeManager + public sealed class FileSystemNodeManager : AsyncCustomNodeManager, IFileSystemHost { /// /// Base URI used to build the namespace URI of a mounted @@ -92,6 +92,34 @@ public FileSystemNodeManager( /// public new ushort NamespaceIndex { get; } + bool IFileSystemHost.AllowCreate => true; + + bool IFileSystemHost.AllowDelete => true; + + bool IFileSystemHost.AllowMoveOrCopy => true; + + bool IFileSystemHost.UsesVirtualDirectoryBrowsing => true; + + string IFileSystemHost.CombineProviderPath(string parent, string name) + { + return CombineProviderPath(parent, name); + } + + NodeId IFileSystemHost.GetParentNodeId(string providerPath) + { + return GetParentNodeId(providerPath); + } + + FileHandle? IFileSystemHost.GetOrCreateHandle(NodeId nodeId, string providerPath) + { + return GetOrCreateHandle(nodeId, providerPath); + } + + void IFileSystemHost.ForgetHandle(NodeId nodeId) + { + ForgetHandle(nodeId); + } + /// public override NodeId New(ISystemContext context, NodeState node) { @@ -399,6 +427,43 @@ internal void ForgetHandle(NodeId nodeId) } } + NodeId IFileSystemHost.BuildDirectoryNodeId(string providerPath) + { + return string.IsNullOrEmpty(providerPath) + ? FileSystemNodeId.BuildRoot(NamespaceIndex) + : FileSystemNodeId.BuildDirectory(providerPath, NamespaceIndex); + } + + NodeId IFileSystemHost.BuildFileNodeId(string providerPath) + { + return FileSystemNodeId.BuildFile(providerPath, NamespaceIndex); + } + + ValueTask IFileSystemHost.OnProviderChangedAsync(CancellationToken cancellationToken) + { + return default; + } + + bool IFileSystemHost.TryGetProviderPath( + NodeId nodeId, + out string providerPath, + out bool isDirectory, + out bool isRoot) + { + if (!FileSystemNodeId.TryParse(nodeId, out FileSystemNodeId parsed)) + { + providerPath = string.Empty; + isDirectory = false; + isRoot = false; + return false; + } + + providerPath = parsed.ProviderPath; + isDirectory = parsed.RootType != FileSystemNodeId.File; + isRoot = parsed.RootType == FileSystemNodeId.Root; + return true; + } + private static string BuildNamespaceUri(IFileSystemProvider provider) { if (provider == null) diff --git a/src/Opc.Ua.Server/FileSystem/IFileSystemHost.cs b/src/Opc.Ua.Server/FileSystem/IFileSystemHost.cs new file mode 100644 index 0000000000..d71db4b878 --- /dev/null +++ b/src/Opc.Ua.Server/FileSystem/IFileSystemHost.cs @@ -0,0 +1,134 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Server.FileSystem +{ + /// + /// Internal bridge used by file-system node states to share FileType and + /// FileDirectoryType method semantics across lazy and materialised hosts. + /// + internal interface IFileSystemHost + { + /// + /// The provider that backs the hosted file system. + /// + IFileSystemProvider Provider { get; } + + /// + /// Whether the host exposes the CreateFile and CreateDirectory methods. + /// + bool AllowCreate { get; } + + /// + /// Whether the host exposes the Delete method. + /// + bool AllowDelete { get; } + + /// + /// Whether the host exposes the MoveOrCopy method. + /// + bool AllowMoveOrCopy { get; } + + /// + /// Whether directory children are resolved on demand rather than + /// materialised as node states up front. + /// + bool UsesVirtualDirectoryBrowsing { get; } + + /// + /// Returns the NodeId that represents a directory at the given + /// provider path. + /// + /// The provider-relative directory path. + /// The directory NodeId. + NodeId BuildDirectoryNodeId(string providerPath); + + /// + /// Returns the NodeId that represents a file at the given provider + /// path. + /// + /// The provider-relative file path. + /// The file NodeId. + NodeId BuildFileNodeId(string providerPath); + + /// + /// Joins a child name onto a parent provider path. + /// + /// The parent provider path. + /// The child name. + /// The combined provider path. + string CombineProviderPath(string parent, string name); + + /// + /// Returns the NodeId of the directory containing the given provider + /// path. + /// + /// The provider-relative path. + /// The parent directory NodeId. + NodeId GetParentNodeId(string providerPath); + + /// + /// Returns the open-file handle tracked for a node, creating it when + /// the node is not tracked yet. + /// + /// The file NodeId. + /// The provider-relative file path. + /// The handle, or null when one cannot be tracked. + FileHandle? GetOrCreateHandle(NodeId nodeId, string providerPath); + + /// + /// Drops the handle tracked for a node. + /// + /// The file NodeId. + void ForgetHandle(NodeId nodeId); + + /// + /// Notifies the host that the provider's contents changed so it can + /// refresh whatever it has materialised. + /// + /// The cancellation token. + /// A task that completes once the host caught up. + ValueTask OnProviderChangedAsync(CancellationToken cancellationToken); + + /// + /// Resolves a NodeId back to the provider path it represents. + /// + /// The NodeId to resolve. + /// The resolved provider-relative path. + /// Whether the path is a directory. + /// Whether the path is the file system root. + /// + /// true when the NodeId belongs to this host. + /// + bool TryGetProviderPath(NodeId nodeId, out string providerPath, out bool isDirectory, out bool isRoot); + } +} diff --git a/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs b/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs index 968f813274..5bab86f472 100644 --- a/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs +++ b/src/Opc.Ua.Server/Hosting/OpcUaServerBuilderExtensions.cs @@ -837,6 +837,21 @@ public static IOpcUaServerBuilder AddFileSystem( return builder.AddFileSystem(new PhysicalFileSystemProvider(rootDirectory, mountName, isWritable)); } + /// + /// Registers the reusable binder that backs existing FileDirectoryType nodes with file-system providers. + /// + /// + public static IOpcUaServerBuilder AddFileDirectoryBinder(this IOpcUaServerBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.TryAddSingleton(); + return builder; + } + /// /// Registers a fluent node manager built from a namespace URI and configuration callback. /// diff --git a/src/Opc.Ua.Server/Location/GeoLocationSample.cs b/src/Opc.Ua.Server/Location/GeoLocationSample.cs new file mode 100644 index 0000000000..74a6bb4bed --- /dev/null +++ b/src/Opc.Ua.Server/Location/GeoLocationSample.cs @@ -0,0 +1,198 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua +{ + /// + /// One reading from an : where something + /// is, how good the reading is, and when it was taken. + /// + /// + /// + /// Both and are optional, and a + /// provider may supply either or both. A satellite receiver reports a + /// ; an asset register that only knows + /// "Building 4, Detroit Plant" reports a label and no coordinates. + /// Consumers that require coordinates - OPC 10000-211 (GPOS) - reject a + /// sample without a , while OPC 10030 (ISA-95) + /// accepts either because its variable carries text. + /// + /// + /// A of + /// asks the consumer to substitute the current UTC time when it applies the + /// sample, which lets a provider that does not track acquisition time stay + /// simple. + /// + /// + /// + /// The geodetic position, or null when the provider only knows a + /// textual location. + /// + /// + /// The orientation of the located body, or null when unknown. + /// Consumers that do not model orientation ignore it. + /// + /// + /// Human-readable location literals - a site name, a postal address, a + /// pre-formatted geometry - published in addition to + /// . May be empty. + /// + /// + /// The OPC UA quality of the reading. + /// + /// + /// When the reading was taken, or to let + /// the consumer substitute the current UTC time. + /// + /// + /// The source this reading belongs to, when the provider chooses to echo + /// it. A consumer that receives a non-null value not matching the + /// source it asked for rejects the sample, which catches a provider + /// mixing up its sources. null disables that check. + /// + public readonly record struct GeoLocationSample( + GeoPosition? Position, + GeoOrientation? Orientation, + ArrayOf Labels, + StatusCode StatusCode, + DateTimeUtc SourceTimestamp, + string? SourceId = null) + { + /// + /// Creates a good-quality sample carrying a position. + /// + /// The geodetic position. + /// + /// When the reading was taken; omit to let the consumer substitute the + /// current UTC time. + /// + /// + /// A good-quality sample. + /// + public static GeoLocationSample Good( + GeoPosition position, + DateTimeUtc sourceTimestamp = default) + { + return new GeoLocationSample( + position, + null, + default, + StatusCodes.Good, + sourceTimestamp); + } + + /// + /// Creates a good-quality sample carrying a position and an + /// orientation. + /// + /// The geodetic position. + /// The orientation of the located body. + /// + /// When the reading was taken; omit to let the consumer substitute the + /// current UTC time. + /// + /// + /// A good-quality sample. + /// + public static GeoLocationSample Good( + GeoPosition position, + GeoOrientation orientation, + DateTimeUtc sourceTimestamp = default) + { + return new GeoLocationSample( + position, + orientation, + default, + StatusCodes.Good, + sourceTimestamp); + } + + /// + /// Creates a good-quality sample carrying only textual location + /// literals, for a source that has no coordinates. + /// + /// The location literals. + /// + /// When the reading was taken; omit to let the consumer substitute the + /// current UTC time. + /// + /// + /// A good-quality sample. + /// + public static GeoLocationSample Good( + ArrayOf labels, + DateTimeUtc sourceTimestamp = default) + { + return new GeoLocationSample( + null, + null, + labels, + StatusCodes.Good, + sourceTimestamp); + } + + /// + /// Creates a sample that carries no location, only the reason one is + /// unavailable. + /// + /// The status explaining the failure. + /// + /// When the failure was observed; omit to let the consumer substitute + /// the current UTC time. + /// + /// + /// A sample carrying . + /// + public static GeoLocationSample Unavailable( + StatusCode statusCode, + DateTimeUtc sourceTimestamp = default) + { + return new GeoLocationSample( + null, + null, + default, + statusCode, + sourceTimestamp); + } + + /// + /// Returns , substituting the current UTC + /// time when the provider left it unset. + /// + /// + /// The effective source timestamp. + /// + public DateTimeUtc GetEffectiveSourceTimestamp() + { + return SourceTimestamp == DateTimeUtc.MinValue + ? DateTimeUtc.Now + : SourceTimestamp; + } + } +} diff --git a/src/Opc.Ua.Server/Location/GeoOrientation.cs b/src/Opc.Ua.Server/Location/GeoOrientation.cs new file mode 100644 index 0000000000..fd1613ea14 --- /dev/null +++ b/src/Opc.Ua.Server/Location/GeoOrientation.cs @@ -0,0 +1,49 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua +{ + /// + /// The orientation of a located body, as the three rotation angles OPC + /// 10000-211 carries alongside a global position. + /// + /// + /// Rotation about the first axis, in degrees. + /// + /// + /// Rotation about the second axis, in degrees. + /// + /// + /// Rotation about the third axis, in degrees. + /// + public readonly record struct GeoOrientation( + double A, + double B, + double C); +} diff --git a/src/Opc.Ua.Server/Location/GeoPosition.cs b/src/Opc.Ua.Server/Location/GeoPosition.cs new file mode 100644 index 0000000000..ea7fe4ecba --- /dev/null +++ b/src/Opc.Ua.Server/Location/GeoPosition.cs @@ -0,0 +1,76 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua +{ + /// + /// A geodetic position expressed as latitude, longitude and height above + /// the reference ellipsoid. + /// + /// + /// This is the technology-neutral shape an + /// reports. Each consuming companion model maps it onto its own + /// specification type: OPC 10000-211 (GPOS) builds a structured global + /// location from it, while OPC 10030 (ISA-95) formats it into the string + /// literal its GeoSpatialLocationType variable carries. + /// + /// + /// Latitude in decimal degrees, positive north of the equator. + /// + /// + /// Longitude in decimal degrees, positive east of the prime meridian. + /// + /// + /// Height in metres above the reference ellipsoid, or null for a + /// two-dimensional fix that carries no altitude. Published as OPC + /// 10000-211's optional Elevation. + /// + /// + /// Estimated horizontal accuracy in metres, or null when the source + /// does not report one. + /// + /// + /// Building floor the position sits on, for indoor sources, or null + /// when not applicable. + /// + /// + /// EPSG code of the coordinate reference system the values are expressed + /// in, or null when the provider does not state one. A consumer that + /// is configured for a specific reference system rejects a position that + /// explicitly declares a different one, and treats null as "trust + /// the configured system". WGS84 is 4326. + /// + public readonly record struct GeoPosition( + double Latitude, + double Longitude, + double? Height = null, + double? Accuracy = null, + float? Floor = null, + uint? EpsgCode = null); +} diff --git a/src/Opc.Ua.Server/Location/IGeoLocationProvider.cs b/src/Opc.Ua.Server/Location/IGeoLocationProvider.cs new file mode 100644 index 0000000000..ac0a52ce33 --- /dev/null +++ b/src/Opc.Ua.Server/Location/IGeoLocationProvider.cs @@ -0,0 +1,100 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua +{ + /// + /// Supplies the location of one or more identified sources to any companion + /// model that publishes location. + /// + /// + /// + /// This is the single seam a server author implements to answer "where is + /// it?". One implementation serves every consumer: OPC 10000-211 (GPOS) + /// builds structured global locations from it, and OPC 10030 (ISA-95) fills + /// its GeoSpatialLocationType variables from it. The contract is + /// deliberately free of any companion-model type so that + /// Opc.Ua.Server stays independent of the models built on top of it. + /// + /// + /// A provider serves many sources, each named by a stable, provider-local + /// sourceId that the host supplies when it binds a variable. + /// Implementations should be safe for concurrent calls; the binding layer + /// does not serialise access. + /// + /// + public interface IGeoLocationProvider + { + /// + /// Whether delivers updates. When + /// false, the host never subscribes and instead polls + /// at the bound variable's sampling interval. + /// + bool SupportsPush { get; } + + /// + /// Reads the current location of a source. + /// + /// + /// The stable, provider-local identifier of the source. + /// + /// + /// A token used to cancel the operation. + /// + /// + /// The current sample. A provider that cannot serve + /// throws rather than returning a sample + /// for a different source. + /// + ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken = default); + + /// + /// Streams location updates for a source until the token is cancelled. + /// Only called when is true. + /// + /// + /// The stable, provider-local identifier of the source. + /// + /// + /// A token that ends the stream when cancelled. + /// + /// + /// An asynchronous stream of samples. + /// + IAsyncEnumerable WatchAsync( + string sourceId, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.Server/Location/IGeoLocationTextFormatter.cs b/src/Opc.Ua.Server/Location/IGeoLocationTextFormatter.cs new file mode 100644 index 0000000000..361c865d83 --- /dev/null +++ b/src/Opc.Ua.Server/Location/IGeoLocationTextFormatter.cs @@ -0,0 +1,54 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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/ + * ======================================================================*/ + +namespace Opc.Ua +{ + /// + /// Renders a as the location literals a + /// text-valued location variable carries. + /// + /// + /// OPC 10030 (ISA-95) models a location as text rather than as coordinates, + /// so a structured sample has to be projected before it can be published. + /// Replace the default to publish + /// a different geometry encoding or a site-specific literal. + /// + public interface IGeoLocationTextFormatter + { + /// + /// Renders a sample as location literals. + /// + /// The sample to render. + /// + /// The literals to publish, most specific first. May be empty when the + /// sample carries no location at all. + /// + ArrayOf Format(in GeoLocationSample sample); + } +} diff --git a/src/Opc.Ua.ISA95.Server/Providers/Isa95GeoSpatialLocationProvider.cs b/src/Opc.Ua.Server/Location/InMemoryGeoLocationProvider.cs similarity index 51% rename from src/Opc.Ua.ISA95.Server/Providers/Isa95GeoSpatialLocationProvider.cs rename to src/Opc.Ua.Server/Location/InMemoryGeoLocationProvider.cs index 7bd12240b4..6b9d47f2ef 100644 --- a/src/Opc.Ua.ISA95.Server/Providers/Isa95GeoSpatialLocationProvider.cs +++ b/src/Opc.Ua.Server/Location/InMemoryGeoLocationProvider.cs @@ -33,90 +33,67 @@ using System.Threading; using System.Threading.Tasks; -namespace Opc.Ua.ISA95.Server.Providers +namespace Opc.Ua { /// - /// In-memory that can be - /// constructed directly as a fallback when no dependency-injection - /// registration is available. The current value can be mutated through - /// and observed through - /// ; a fault can be injected with - /// to surface an error to readers. + /// In-memory for tests, samples and + /// servers whose locations are pushed in from elsewhere. /// - public sealed class Isa95GeoSpatialLocationProvider - : IIsa95GeoSpatialLocationProvider, IDisposable + /// + /// Sources appear the first time they are written with + /// . Readers observe changes + /// through , and makes a source + /// fail so error handling can be exercised. + /// + public sealed class InMemoryGeoLocationProvider : IGeoLocationProvider, IDisposable { - /// - /// Initializes a new instance of the - /// class with an initial - /// good-quality value. - /// - /// - /// The initial geospatial location literal, or null. - /// - public Isa95GeoSpatialLocationProvider(string? initialValue = null) - : this(Isa95GeoSpatialLocation.Good(initialValue)) - { - } - - /// - /// Initializes a new instance of the - /// class. - /// - /// - /// The initial geospatial location snapshot. - /// - public Isa95GeoSpatialLocationProvider(Isa95GeoSpatialLocation initial) - { - m_current = initial; - } - - /// - /// Gets the current snapshot held by the provider. - /// - public Isa95GeoSpatialLocation Current - { - get - { - lock (m_lock) - { - return m_current; - } - } - } + /// + public bool SupportsPush => true; /// - public ValueTask GetCurrentAsync( + public ValueTask ReadAsync( + string sourceId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + ValidateSourceId(sourceId); Exception? fault; - Isa95GeoSpatialLocation current; + GeoLocationSample current; lock (m_lock) { ThrowIfDisposed(); - fault = m_fault; - current = m_current; + if (!m_sources.TryGetValue(sourceId, out SourceState? source)) + { + return new ValueTask( + Task.FromException( + ServiceResultException.Create( + StatusCodes.BadNotFound, + "No location is held for source '{0}'.", + sourceId))); + } + fault = source.Fault; + current = source.Current; } - if (fault != null) - { - return new ValueTask( - Task.FromException(fault)); - } - return new ValueTask(current); + return fault != null + ? new ValueTask( + Task.FromException(fault)) + : new ValueTask(current); } /// - public async IAsyncEnumerable SubscribeAsync( + public async IAsyncEnumerable WatchAsync( + string sourceId, [EnumeratorCancellation] CancellationToken cancellationToken = default) { + ValidateSourceId(sourceId); + var subscription = new Subscription(); lock (m_lock) { ThrowIfDisposed(); - m_subscribers.Add(subscription); + GetOrAddSource(sourceId).Subscribers.Add(subscription); } try @@ -126,102 +103,106 @@ public async IAsyncEnumerable SubscribeAsync( bool cancelled = false; try { - await subscription.WaitAsync(cancellationToken).ConfigureAwait(false); + await subscription.WaitAsync(cancellationToken) + .ConfigureAwait(false); } catch (OperationCanceledException) { cancelled = true; } - if (cancelled) + if (cancelled || + !subscription.TryDequeue(out GeoLocationSample sample)) { yield break; } - if (!subscription.TryDequeue(out Isa95GeoSpatialLocation location)) - { - yield break; - } - - yield return location; + yield return sample; } } finally { lock (m_lock) { - m_subscribers.Remove(subscription); + if (m_sources.TryGetValue(sourceId, out SourceState? source)) + { + source.Subscribers.Remove(subscription); + } } subscription.Dispose(); } } /// - /// Replaces the current value and notifies subscribers. Clears any - /// previously injected fault. + /// Replaces the location of a source and notifies its watchers, + /// clearing any fault previously injected for it. /// - /// - /// The new geospatial location snapshot. - /// - public void Update(Isa95GeoSpatialLocation location) + /// The source to update. + /// The new sample. + public void Update(string sourceId, GeoLocationSample sample) { + ValidateSourceId(sourceId); + List subscribers; lock (m_lock) { ThrowIfDisposed(); - m_current = location; - m_fault = null; - subscribers = [.. m_subscribers]; + SourceState source = GetOrAddSource(sourceId); + source.Current = sample; + source.Fault = null; + subscribers = [.. source.Subscribers]; } - foreach (Subscription subscriber in subscribers) + for (int ii = 0; ii < subscribers.Count; ii++) { - subscriber.Enqueue(location); + subscribers[ii].Enqueue(sample); } } /// - /// Replaces the current value and notifies subscribers. + /// Replaces the location of a source with a good-quality position. /// - /// - /// The geospatial location literal. - /// - /// - /// The status code describing the quality of the value. - /// + /// The source to update. + /// The new position. /// - /// The UTC source timestamp for the value. + /// When the reading was taken; omit to let the consumer substitute the + /// current UTC time. /// - public void Update(string? value, StatusCode statusCode, DateTime sourceTimestamp) + public void Update( + string sourceId, + GeoPosition position, + DateTimeUtc sourceTimestamp = default) { - Update(new Isa95GeoSpatialLocation(value, statusCode, sourceTimestamp)); + Update(sourceId, GeoLocationSample.Good(position, sourceTimestamp)); } /// - /// Injects a fault that causes subsequent - /// calls to fail until the next . + /// Makes a source fail until it is next updated. /// - /// - /// The error to surface to readers. - /// - /// is null. - public void Fault(Exception error) + /// The source to fault. + /// The error to surface to readers. + /// + /// is null. + /// + public void Fault(string sourceId, Exception error) { + ValidateSourceId(sourceId); if (error == null) { throw new ArgumentNullException(nameof(error)); } + lock (m_lock) { ThrowIfDisposed(); - m_fault = error; + GetOrAddSource(sourceId).Fault = error; } } /// public void Dispose() { - List subscribers; + var subscribers = new List(); lock (m_lock) { if (m_disposed) @@ -229,13 +210,37 @@ public void Dispose() return; } m_disposed = true; - subscribers = [.. m_subscribers]; - m_subscribers.Clear(); + foreach (SourceState source in m_sources.Values) + { + subscribers.AddRange(source.Subscribers); + source.Subscribers.Clear(); + } + m_sources.Clear(); } - foreach (Subscription subscriber in subscribers) + for (int ii = 0; ii < subscribers.Count; ii++) { - subscriber.Complete(); + subscribers[ii].Complete(); + } + } + + private SourceState GetOrAddSource(string sourceId) + { + if (!m_sources.TryGetValue(sourceId, out SourceState? source)) + { + source = new SourceState(); + m_sources.Add(sourceId, source); + } + return source; + } + + private static void ValidateSourceId(string sourceId) + { + if (string.IsNullOrWhiteSpace(sourceId)) + { + throw new ArgumentException( + "A stable source identifier is required.", + nameof(sourceId)); } } @@ -243,23 +248,33 @@ private void ThrowIfDisposed() { if (m_disposed) { - throw new ObjectDisposedException(nameof(Isa95GeoSpatialLocationProvider)); + throw new ObjectDisposedException(nameof(InMemoryGeoLocationProvider)); } } private readonly Lock m_lock = new(); - private readonly List m_subscribers = []; - private Isa95GeoSpatialLocation m_current; - private Exception? m_fault; + private readonly Dictionary m_sources = []; private bool m_disposed; /// - /// Per-consumer buffered subscription used to hand geospatial updates to - /// a single enumerator. + /// The value, fault and watchers held for one source. + /// + private sealed class SourceState + { + public List Subscribers { get; } = []; + + public GeoLocationSample Current { get; set; } + + public Exception? Fault { get; set; } + } + + /// + /// Per-consumer buffered subscription handing updates to a single + /// enumerator. /// private sealed class Subscription : IDisposable { - public void Enqueue(Isa95GeoSpatialLocation location) + public void Enqueue(GeoLocationSample sample) { lock (m_gate) { @@ -267,7 +282,7 @@ public void Enqueue(Isa95GeoSpatialLocation location) { return; } - m_queue.Enqueue(location); + m_queue.Enqueue(sample); m_signal.Release(); } } @@ -290,17 +305,17 @@ public Task WaitAsync(CancellationToken cancellationToken) return m_signal.WaitAsync(cancellationToken); } - public bool TryDequeue(out Isa95GeoSpatialLocation location) + public bool TryDequeue(out GeoLocationSample sample) { lock (m_gate) { if (m_queue.Count > 0) { - location = m_queue.Dequeue(); + sample = m_queue.Dequeue(); return true; } } - location = default; + sample = default; return false; } @@ -319,7 +334,7 @@ public void Dispose() private readonly Lock m_gate = new(); private readonly SemaphoreSlim m_signal = new(0); - private readonly Queue m_queue = new(); + private readonly Queue m_queue = new(); private bool m_completed; private bool m_disposed; } diff --git a/src/Opc.Ua.Server/Location/WktGeoLocationTextFormatter.cs b/src/Opc.Ua.Server/Location/WktGeoLocationTextFormatter.cs new file mode 100644 index 0000000000..30b04f4d6d --- /dev/null +++ b/src/Opc.Ua.Server/Location/WktGeoLocationTextFormatter.cs @@ -0,0 +1,115 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Globalization; + +namespace Opc.Ua +{ + /// + /// Renders a sample's position as an OGC Well-Known Text geometry, then + /// appends the sample's own literals. + /// + /// + /// A position becomes POINT Z (longitude latitude height), using + /// WKT's x-y-z axis order and the invariant culture, so the literal is + /// machine-parseable and round-trips. When the sample declares an EPSG code + /// the geometry is prefixed as SRID=code;POINT Z (โ€ฆ), following the + /// widely used extended-WKT convention, because a coordinate literal is + /// ambiguous without its reference system. + /// + public sealed class WktGeoLocationTextFormatter : IGeoLocationTextFormatter + { + /// + /// A shared instance; the formatter is stateless. + /// + public static WktGeoLocationTextFormatter Instance { get; } = new(); + + /// + public ArrayOf Format(in GeoLocationSample sample) + { + ArrayOf labels = sample.Labels; + if (sample.Position is not GeoPosition position) + { + return labels; + } + + var literals = new List(labels.Count + 1) + { + FormatPoint(position) + }; + for (int ii = 0; ii < labels.Count; ii++) + { + literals.Add(labels[ii]); + } + return literals.ToArray().ToArrayOf(); + } + + /// + /// Renders a position as a WKT point geometry. + /// + /// The position to render. + /// + /// The WKT literal, prefixed with an SRID when the position declares + /// an EPSG code. + /// + public static string FormatPoint(GeoPosition position) + { + // A position without a height is a two-dimensional geometry; WKT + // spells that POINT rather than POINT Z. + string point = position.Height is double height + ? string.Format( + CultureInfo.InvariantCulture, + "POINT Z ({0} {1} {2})", + FormatCoordinate(position.Longitude), + FormatCoordinate(position.Latitude), + FormatCoordinate(height)) + : string.Format( + CultureInfo.InvariantCulture, + "POINT ({0} {1})", + FormatCoordinate(position.Longitude), + FormatCoordinate(position.Latitude)); + return position.EpsgCode is uint epsg + ? string.Format( + CultureInfo.InvariantCulture, + "SRID={0};{1}", + epsg, + point) + : point; + } + + private static string FormatCoordinate(double value) + { + // A fixed-point round-trip format: enough digits to preserve a + // double, without the exponent notation "R" produces for small + // magnitudes, which many WKT readers reject. + return value.ToString("0.#################", CultureInfo.InvariantCulture); + } + } +} diff --git a/src/Opc.Ua.Server/StateMachines/FiniteStateMachineDispatcher.cs b/src/Opc.Ua.Server/StateMachines/FiniteStateMachineDispatcher.cs new file mode 100644 index 0000000000..c8e04a9ebe --- /dev/null +++ b/src/Opc.Ua.Server/StateMachines/FiniteStateMachineDispatcher.cs @@ -0,0 +1,251 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Globalization; + +namespace Opc.Ua.Server.StateMachines +{ + /// + /// Well-known metadata for one finite-state-machine state or transition. + /// + /// + /// The numeric identifier in the state machine namespace. + /// + /// + /// The StateNumber or TransitionNumber value. + /// + /// + /// The display name written to the state variable. + /// + public readonly record struct FiniteStateMachineEntry(uint Id, uint Number, string Name); + + /// + /// Writes Part 16 CurrentState and LastTransition variables for generated state machines. + /// + /// + /// Source-generated companion state machines do not currently emit StateTable and TransitionTable overrides. + /// This dispatcher updates the standard state variables directly while preserving the generated node hierarchy. + /// + public sealed class FiniteStateMachineDispatcher + { + private readonly ushort m_namespaceIndex; + private readonly ArrayOf m_states; + private readonly ArrayOf m_transitions; + + /// + /// Initializes a new instance of the class. + /// + public FiniteStateMachineDispatcher( + ushort namespaceIndex, + ArrayOf states, + ArrayOf transitions) + { + m_namespaceIndex = namespaceIndex; + m_states = states; + m_transitions = transitions; + } + + /// + /// Initializes the current state and clears any previous transition. + /// + public void InitializeToInitialState( + FiniteStateMachineState machine, + uint stateId, + ISystemContext context) + { + ApplyState(machine, stateId, context); + ClearLastTransition(machine); + } + + /// + /// Writes CurrentState for the specified state identifier. + /// + public void ApplyState( + FiniteStateMachineState machine, + uint stateId, + ISystemContext context) + { + if (machine?.CurrentState is null) + { + return; + } + + FiniteStateMachineEntry entry = Lookup(m_states, stateId, "Unknown"); + machine.CurrentState.AddNumber(context); + machine.CurrentState.Value = new LocalizedText(entry.Name); + + if (machine.CurrentState.Id is { } idVariable) + { + idVariable.Value = new NodeId(stateId, m_namespaceIndex); + } + if (machine.CurrentState.Number is { } numberVariable) + { + numberVariable.Value = entry.Number; + } + + machine.CurrentState.ClearChangeMasks(context, includeChildren: true); + } + + /// + /// Writes LastTransition for the specified transition identifier. + /// + public void ApplyTransition( + FiniteStateMachineState machine, + uint transitionId, + ISystemContext context) + { + if (machine is null) + { + return; + } + + EnsureLastTransition(machine, context); + if (machine.LastTransition is null) + { + return; + } + + FiniteStateMachineEntry entry = Lookup(m_transitions, transitionId, "Unknown"); + machine.LastTransition.AddNumber(context); + machine.LastTransition.Value = new LocalizedText(entry.Name); + + if (machine.LastTransition.Id is { } idVariable) + { + idVariable.Value = new NodeId(transitionId, m_namespaceIndex); + } + if (machine.LastTransition.Number is { } numberVariable) + { + numberVariable.Value = entry.Number; + } + if (machine.LastTransition.TransitionTime is { } transitionTimeVariable) + { + transitionTimeVariable.Value = DateTime.UtcNow; + } + + machine.LastTransition.ClearChangeMasks(context, includeChildren: true); + } + + /// + /// Writes CurrentState and LastTransition for one completed move. + /// + public void Move( + FiniteStateMachineState machine, + uint toStateId, + uint transitionId, + ISystemContext context) + { + ApplyState(machine, toStateId, context); + ApplyTransition(machine, transitionId, context); + } + + /// + /// Attempts to read the numeric current state identifier. + /// + public bool TryGetCurrentState(FiniteStateMachineState machine, out uint stateId) + { + stateId = 0; + if (machine?.CurrentState?.Id == null) + { + return false; + } + + NodeId nodeId = machine.CurrentState.Id.Value; + if (nodeId.IsNull || nodeId.NamespaceIndex != m_namespaceIndex) + { + return false; + } + + if (nodeId.TryGetValue(out uint id)) + { + stateId = id; + return true; + } + if (uint.TryParse(nodeId.IdentifierAsString, NumberStyles.Integer, CultureInfo.InvariantCulture, + out uint parsedId)) + { + stateId = parsedId; + return true; + } + + return false; + } + + private static void ClearLastTransition(FiniteStateMachineState machine) + { + if (machine?.LastTransition is null) + { + return; + } + + machine.LastTransition.Value = default; + + if (machine.LastTransition.Id is { } idVariable) + { + idVariable.Value = default; + } + if (machine.LastTransition.Number is { } numberVariable) + { + numberVariable.Value = 0; + } + if (machine.LastTransition.TransitionTime is { } transitionTimeVariable) + { + transitionTimeVariable.Value = DateTime.MinValue; + } + } + + private static void EnsureLastTransition( + FiniteStateMachineState machine, + ISystemContext context) + { + if (machine.LastTransition is not null) + { + return; + } + + machine.AddLastTransition(context); + } + + private static FiniteStateMachineEntry Lookup( + ArrayOf table, + uint id, + string fallbackName) + { + for (int ii = 0; ii < table.Count; ii++) + { + if (table[ii].Id == id) + { + return table[ii]; + } + } + + return new FiniteStateMachineEntry(id, 0, fallbackName); + } + } +} diff --git a/src/Opc.Ua.Types/Attributes/ModelFluentAccessorProviderAttribute.cs b/src/Opc.Ua.Types/Attributes/ModelFluentAccessorProviderAttribute.cs new file mode 100644 index 0000000000..d1d383c14a --- /dev/null +++ b/src/Opc.Ua.Types/Attributes/ModelFluentAccessorProviderAttribute.cs @@ -0,0 +1,62 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua +{ + /// + /// Identifies an assembly that contains source-generated typed fluent + /// accessors for an OPC UA model. + /// + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true, Inherited = false)] + public sealed class ModelFluentAccessorProviderAttribute : Attribute + { + /// + /// Initializes a new instance. + /// + /// The OPC UA model URI. + /// The C# namespace containing the accessors. + public ModelFluentAccessorProviderAttribute(string modelUri, string prefix) + { + ModelUri = modelUri; + Prefix = prefix; + } + + /// + /// The OPC UA model URI. + /// + public string ModelUri { get; } + + /// + /// The C# namespace containing the generated accessors. + /// + public string Prefix { get; } + } +} diff --git a/src/Opc.Ua.Types/Encoders/JsonDecoder.cs b/src/Opc.Ua.Types/Encoders/JsonDecoder.cs index dd1fb192a4..ce00c49c00 100644 --- a/src/Opc.Ua.Types/Encoders/JsonDecoder.cs +++ b/src/Opc.Ua.Types/Encoders/JsonDecoder.cs @@ -2831,6 +2831,17 @@ internal bool TryGetEncodeableFromElement( out T value) where T : IEncodeable { + if (!encodeableTypeId.IsAbsolute) + { + var absoluteId = NodeId.ToExpandedNodeId( + encodeableTypeId.InnerNodeId, + Context.NamespaceUris); + if (!absoluteId.IsNull) + { + encodeableTypeId = absoluteId; + } + } + if (!Context.Factory.TryGetEncodeableType( encodeableTypeId, out IEncodeableType? activator)) @@ -3958,9 +3969,10 @@ private bool TryGetEncodingMaskFromElement( value = default; return true; case JsonValueKind.Object: - if (TryGetUInt32FromElement( - GetPropertyElement(JsonProperties.EncodingMask), - out value)) + JsonElement encodingMask = + GetPropertyElement(JsonProperties.EncodingMask); + if (encodingMask.ValueKind != JsonValueKind.Undefined && + TryGetUInt32FromElement(encodingMask, out value)) { return true; } diff --git a/src/Opc.Ua.Types/Polyfills/System.cs b/src/Opc.Ua.Types/Polyfills/System.cs index ca7c29d06c..894b28964b 100644 --- a/src/Opc.Ua.Types/Polyfills/System.cs +++ b/src/Opc.Ua.Types/Polyfills/System.cs @@ -45,6 +45,30 @@ namespace System /// public static class Polyfills { + /// + /// Throws when a reference argument is null. + /// + /// The reference type to validate. + /// The reference to validate. + /// The argument name. + /// The non-null reference. + /// + public static T ThrowIfNull( + this T? target, + string parameterName) + where T : class + { + return target ?? throw new ArgumentNullException(parameterName); + } + + /// + /// Returns true when a double is neither NaN nor infinity. + /// + public static bool IsFinite(this double target) + { + return !double.IsNaN(target) && !double.IsInfinity(target); + } + #if NETSTANDARD2_0 || NETFRAMEWORK /// /// Return hash code assuming ordinal diff --git a/src/Opc.Ua.Types/State/NodeIdFactorySuppressedContext.cs b/src/Opc.Ua.Types/State/NodeIdFactorySuppressedContext.cs new file mode 100644 index 0000000000..b5dc49c5f6 --- /dev/null +++ b/src/Opc.Ua.Types/State/NodeIdFactorySuppressedContext.cs @@ -0,0 +1,97 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Types; + +namespace Opc.Ua +{ + /// + /// Forwards every member of a system context except its + /// , which is reported as absent. + /// + /// + /// A node copy materialises its children through + /// , and the + /// CreateOrReplace<Child> plumbing behind it assigns a + /// per-instance NodeId whenever the context carries a factory. In a copy + /// every child is initialised from its source immediately afterwards, which + /// overwrites that NodeId, so the assignment only consumes identifiers - + /// and permanently leaks them for factories that track outstanding + /// allocations. Hiding the factory for the duration of the copy leaves + /// those identifiers unused; the assignInstanceNodeIds flag cannot + /// serve here because it is not part of the virtual FindChild + /// contract the copy goes through. + /// + internal sealed class NodeIdFactorySuppressedContext : ISystemContext + { + /// + /// Wraps the supplied context. + /// + /// The context to forward to. + public NodeIdFactorySuppressedContext(ISystemContext context) + { + m_context = context; + } + + /// + public object? SystemHandle => m_context.SystemHandle; + + /// + public string? UserId => m_context.UserId; + + /// + public ArrayOf PreferredLocales => m_context.PreferredLocales; + + /// + public string? AuditEntryId => m_context.AuditEntryId; + + /// + public NamespaceTable NamespaceUris => m_context.NamespaceUris; + + /// + public StringTable ServerUris => m_context.ServerUris; + + /// + public ITypeTable TypeTable => m_context.TypeTable; + + /// + public IEncodeableFactory EncodeableFactory => m_context.EncodeableFactory; + + /// + public INodeIdFactory NodeIdFactory => null!; + + /// + public NodeStateFactory NodeStateFactory => m_context.NodeStateFactory; + + /// + public ITelemetryContext Telemetry => m_context.Telemetry; + + private readonly ISystemContext m_context; + } +} diff --git a/src/Opc.Ua.Types/State/NodeState.cs b/src/Opc.Ua.Types/State/NodeState.cs index 98a4022010..18d3a6bdfb 100644 --- a/src/Opc.Ua.Types/State/NodeState.cs +++ b/src/Opc.Ua.Types/State/NodeState.cs @@ -335,10 +335,17 @@ protected virtual void Initialize(ISystemContext context, NodeState source) var children = new List(); source.GetChildren(context, children); + // Every child created below is initialized from its source right + // afterwards, which overwrites the NodeId a factory would hand out + // here, so the copy must not reach the factory at all. + ISystemContext childContext = children.Count > 0 && context.NodeIdFactory != null + ? new NodeIdFactorySuppressedContext(context) + : context; + for (int ii = 0; ii < children.Count; ii++) { BaseInstanceState sourceChild = children[ii]; - BaseInstanceState? child = CreateChild(context, sourceChild.BrowseName); + BaseInstanceState? child = CreateChild(childContext, sourceChild.BrowseName); if (child == null) { diff --git a/src/Opc.Ua.Types/Utils/FileSystem/VirtualFileSystem.cs b/src/Opc.Ua.Types/Utils/FileSystem/VirtualFileSystem.cs index 07233ab644..ae89114f2f 100644 --- a/src/Opc.Ua.Types/Utils/FileSystem/VirtualFileSystem.cs +++ b/src/Opc.Ua.Types/Utils/FileSystem/VirtualFileSystem.cs @@ -259,7 +259,13 @@ public VirtualFile(string filePath, bool createFromFile) File = MemoryMappedFile.CreateNew( GetMapName(), - 32 * 1024 * 1024, + // Reservation, not an allocation: DelayAllocatePages + // commits pages on demand, so this only costs address + // space. It has to clear the largest single generated + // file - the source generator writes whole model + // sources through this file system, and a large + // companion model already exceeds 32 MB. + 256 * 1024 * 1024, MemoryMappedFileAccess.ReadWrite, MemoryMappedFileOptions.DelayAllocatePages, HandleInheritability.None); diff --git a/tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj b/tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj index f3783f8d62..42980bf021 100644 --- a/tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj +++ b/tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj @@ -28,6 +28,9 @@ + + + diff --git a/tests/Opc.Ua.Aot.Tests/PositioningAotTests.cs b/tests/Opc.Ua.Aot.Tests/PositioningAotTests.cs new file mode 100644 index 0000000000..e1aa991c69 --- /dev/null +++ b/tests/Opc.Ua.Aot.Tests/PositioningAotTests.cs @@ -0,0 +1,156 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Opc.Ua.Gpos; +using Opc.Ua.Positioning; +using Opc.Ua.Positioning.Client; +using Opc.Ua.Positioning.Server; + +namespace Opc.Ua.Aot.Tests +{ + /// + /// NativeAOT reachability tests for the Positioning model, transforms, + /// client registration, and server registration. + /// + public sealed class PositioningAotTests + { + [Test] + public async Task PositioningModelsTransformsAndHostingAreReachableAsync() + { + ITelemetryContext telemetry = DefaultTelemetry.Create( + builder => builder.SetMinimumLevel(LogLevel.Warning)); + var clientConfiguration = new ApplicationConfiguration(telemetry) + { + ApplicationName = "PositioningAotClient", + ApplicationType = ApplicationType.Client, + ClientConfiguration = new ClientConfiguration(), + SecurityConfiguration = new SecurityConfiguration(), + TransportQuotas = new TransportQuotas() + }; + var services = new ServiceCollection(); + services.AddOpcUa() + .AddServer(options => + { + options.ApplicationName = "PositioningAotServer"; + options.AutoAcceptUntrustedCertificates = true; + }) + .AddPositioningServer(); + services.AddOpcUa() + .AddClient(options => + options.Configuration = clientConfiguration) + .AddPositioningClient(); + + using ServiceProvider provider = services.BuildServiceProvider(); + PositioningNodeManagerFactory factory = + provider.GetRequiredService(); + Func> + relativeFactory = provider.GetRequiredService< + Func>>(); + Func> + globalFactory = provider.GetRequiredService< + Func>>(); + + var frame = new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates + { + X = 1.0, + Y = 2.0, + Z = 3.0 + }, + Orientation = new ThreeDOrientation { C = 45.0 } + }; + var transform = RslFrameTransform.FromFrame( + frame, + AngleUnit.Degrees); + ThreeDCartesianCoordinates transformed = transform.TransformPoint( + new ThreeDCartesianCoordinates()); + + ArrayOf controlPoints = + [ + CreateControlPoint(0.0, 0.0, 8.0, 47.0), + CreateControlPoint(1.0, 0.0, 8.00001, 47.0), + CreateControlPoint(0.0, 1.0, 8.0, 47.00001) + ]; + GroundControlPointFitResult fit = + new GroundControlPointFitter().Fit(controlPoints); + ThreeDCartesianCoordinates local = fit.GlobalToLocal( + controlPoints[0].GlobalPosition, + AngleUnit.Degrees); + + var context = + ServiceMessageContext.Create(telemetry); + context.NamespaceUris.GetIndexOrAppend(Rsl.Namespaces.RSL); + context.NamespaceUris.GetIndexOrAppend(Gpos.Namespaces.GPOS); + context.Factory.Builder.AddOpcUaGpos().Commit(); + var location = new GlobalLocationDataType + { + Position = new GlobalPositionDataType + { + Longitude = 8.0, + Latitude = 47.0 + } + }; + byte[] encoded = BinaryEncoder.EncodeMessage(location, context); + GlobalLocationDataType decoded = BinaryDecoder.DecodeMessage( + encoded, + context); + + await Assert.That(factory.NamespacesUris.Count).IsEqualTo(2); + await Assert.That(relativeFactory).IsNotNull(); + await Assert.That(globalFactory).IsNotNull(); + await Assert.That(transformed.X).IsEqualTo(1.0); + await Assert.That(double.IsFinite(local.X)).IsTrue(); + await Assert.That(location.IsEqual(decoded)).IsTrue(); + } + + private static GroundControlPointDataType CreateControlPoint( + double x, + double y, + double longitude, + double latitude) + { + return new GroundControlPointDataType + { + LocalPosition = new ThreeDCartesianCoordinates + { + X = x, + Y = y + }, + GlobalPosition = new S3DGeographicCoordinateDataType + { + Longitude = longitude, + Latitude = latitude + } + }; + } + } +} diff --git a/tests/Opc.Ua.Aot.Tests/SharedGeoLocationProviderTests.cs b/tests/Opc.Ua.Aot.Tests/SharedGeoLocationProviderTests.cs new file mode 100644 index 0000000000..c72780d65e --- /dev/null +++ b/tests/Opc.Ua.Aot.Tests/SharedGeoLocationProviderTests.cs @@ -0,0 +1,112 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Gpos; +using Opc.Ua.ISA95.Server.Builders; +using Opc.Ua.Positioning.Server; + +namespace Opc.Ua.Aot.Tests +{ + /// + /// Proves the point of the shared seam: + /// a single provider implementation drives both the OPC 10000-211 (GPOS) + /// coordinate model and the OPC 10030 (ISA-95) text model, with no + /// per-model provider code. + /// + public sealed class SharedGeoLocationProviderTests + { + private const string SourceId = "reactor-1"; + private static readonly string[] s_zurichPlant = ["Building 4, Zurich Plant"]; + private static readonly string[] s_detroitPlant = ["Building 4, Detroit Plant"]; + + [Test] + public async Task OneProviderDrivesBothTheGposAndIsa95ModelsAsync() + { + using var provider = new InMemoryGeoLocationProvider(); + provider.Update( + SourceId, + new GeoLocationSample( + new GeoPosition(47.3769, 8.5417, 408.0, EpsgCode: 4326), + new GeoOrientation(0.0, 0.0, 90.0), + s_zurichPlant.ToArrayOf(), + StatusCodes.Good, + DateTimeUtc.Now)); + + // GPOS consumes the structured position. + GeoLocationSample sample = await provider + .ReadAsync(SourceId, CancellationToken.None).ConfigureAwait(false); + await Assert.That(sample.Position.HasValue).IsTrue(); + await Assert.That(sample.Position!.Value.Height).IsEqualTo(408.0); + await Assert.That(sample.Orientation!.Value.C).IsEqualTo(90.0); + + // ISA-95 consumes the projected text, from the very same sample. + ArrayOf literals = WktGeoLocationTextFormatter.Instance + .Format(sample); + await Assert.That(literals.Count).IsEqualTo(2); + await Assert.That(literals[0]) + .IsEqualTo("SRID=4326;POINT Z (8.5417 47.3769 408)"); + await Assert.That(literals[1]).IsEqualTo("Building 4, Zurich Plant"); + } + + [Test] + public async Task ATextOnlySourceIsRejectedByGposButServesIsa95Async() + { + using var provider = new InMemoryGeoLocationProvider(); + provider.Update( + SourceId, + GeoLocationSample.Good( + s_detroitPlant.ToArrayOf())); + + GeoLocationSample sample = await provider + .ReadAsync(SourceId, CancellationToken.None).ConfigureAwait(false); + + // No coordinates, so a GPOS Variable cannot publish it... + await Assert.That(sample.Position.HasValue).IsFalse(); + + // ...but ISA-95 publishes the literal unchanged. + ArrayOf literals = WktGeoLocationTextFormatter.Instance + .Format(sample); + await Assert.That(literals.Count).IsEqualTo(1); + await Assert.That(literals[0]).IsEqualTo("Building 4, Detroit Plant"); + } + + [Test] + public async Task TheSharedContractDoesNotLiveInACompanionModelAsync() + { + // Both companion server assemblies bind the same contract type, + // which must sit below them so neither depends on the other. + await Assert.That( + typeof(PositioningAddressSpaceBuilder).Assembly == + typeof(Isa95GeoSpatialLocationBinder).Assembly).IsFalse(); + await Assert.That( + typeof(IGeoLocationProvider).Assembly == + typeof(GlobalLocationDataType).Assembly).IsFalse(); + } + } +} \ No newline at end of file diff --git a/tests/Opc.Ua.Di.Tests/Opc.Ua.Di.Tests.csproj b/tests/Opc.Ua.Di.Tests/Opc.Ua.Di.Tests.csproj index b72b28ec72..f4740a99fe 100644 --- a/tests/Opc.Ua.Di.Tests/Opc.Ua.Di.Tests.csproj +++ b/tests/Opc.Ua.Di.Tests/Opc.Ua.Di.Tests.csproj @@ -9,6 +9,7 @@ + @@ -34,12 +35,21 @@ + + + + + @@ -50,6 +60,9 @@ + + + diff --git a/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs b/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs index 81551a326c..986bd18fb7 100644 --- a/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs @@ -61,9 +61,16 @@ public sealed class PumpInstanceNodeIdRegressionTests /// path relative to the pump root. Captured from the manager before the /// generated helpers took over NodeId assignment, so a child that stops /// being materialised - or that appears out of nowhere - fails here. + /// Also covers the OpenUSD representation, component and signal nodes + /// the sample adds on top of the DI/Pumps model. /// private static readonly string[] s_expectedPumpSurface = [ + "AlarmActive", + "Bearing", + "Bearing/OpenUsdRepresentation", + "Bearing/OpenUsdRepresentation/PrimPath", + "Bearing/OpenUsdRepresentation/Stage", "Events", "Events/OverTempAlarm", // The fluent alarm builder attaches four unnamed condition @@ -76,10 +83,14 @@ public sealed class PumpInstanceNodeIdRegressionTests "Events/OverTempAlarm/AckedState/Id", "Events/OverTempAlarm/Acknowledge", "Events/OverTempAlarm/Acknowledge/InputArguments", + // Argument properties on argument-less standard methods, as pinned + // by the source generator change earlier in this stack. + "Events/OverTempAlarm/Acknowledge/OutputArguments", "Events/OverTempAlarm/ActiveState", "Events/OverTempAlarm/ActiveState/Id", "Events/OverTempAlarm/AddComment", "Events/OverTempAlarm/AddComment/InputArguments", + "Events/OverTempAlarm/AddComment/OutputArguments", "Events/OverTempAlarm/BranchId", "Events/OverTempAlarm/ClientUserId", "Events/OverTempAlarm/Comment", @@ -88,7 +99,11 @@ public sealed class PumpInstanceNodeIdRegressionTests "Events/OverTempAlarm/ConditionClassName", "Events/OverTempAlarm/ConditionName", "Events/OverTempAlarm/Disable", + "Events/OverTempAlarm/Disable/InputArguments", + "Events/OverTempAlarm/Disable/OutputArguments", "Events/OverTempAlarm/Enable", + "Events/OverTempAlarm/Enable/InputArguments", + "Events/OverTempAlarm/Enable/OutputArguments", "Events/OverTempAlarm/EnabledState", "Events/OverTempAlarm/EnabledState/Id", "Events/OverTempAlarm/EventId", @@ -118,7 +133,91 @@ public sealed class PumpInstanceNodeIdRegressionTests "Identification/Manufacturer", "Identification/ProductInstanceUri", "Identification/SerialNumber", + "Impeller", + "Impeller/OpenUsdRepresentation", + "Impeller/OpenUsdRepresentation/PrimPath", + "Impeller/OpenUsdRepresentation/Stage", "Maintenance", + "OpenUsdRepresentation", + "OpenUsdRepresentation/AlarmActiveVisibility", + "OpenUsdRepresentation/AlarmActiveVisibility/AlarmAspect", + "OpenUsdRepresentation/AlarmActiveVisibility/BadQualityAction", + "OpenUsdRepresentation/AlarmActiveVisibility/BindingDefinitionId", + "OpenUsdRepresentation/AlarmActiveVisibility/Enabled", + "OpenUsdRepresentation/AlarmActiveVisibility/RenderTargetKind", + "OpenUsdRepresentation/AlarmActiveVisibility/Scale", + "OpenUsdRepresentation/AlarmActiveVisibility/SignalRole", + "OpenUsdRepresentation/AlarmActiveVisibility/SourceNodeId", + "OpenUsdRepresentation/AlarmActiveVisibility/TargetPrimPath", + "OpenUsdRepresentation/AlarmActiveVisibility/TargetPropertyName", + "OpenUsdRepresentation/AlarmActiveVisibility/TargetStage", + "OpenUsdRepresentation/AlarmActiveVisibility/TargetUsdTypeName", + "OpenUsdRepresentation/BearingComponent", + "OpenUsdRepresentation/BearingComponent/BindingDefinitionId", + "OpenUsdRepresentation/BearingComponent/Cardinality", + "OpenUsdRepresentation/BearingComponent/ComponentRepresentation", + "OpenUsdRepresentation/BearingComponent/CompositionArc", + "OpenUsdRepresentation/BearingComponent/Enabled", + "OpenUsdRepresentation/BearingComponent/TargetPrimPath", + "OpenUsdRepresentation/BearingTempColor", + "OpenUsdRepresentation/BearingTempColor/BadQualityAction", + "OpenUsdRepresentation/BearingTempColor/BindingDefinitionId", + "OpenUsdRepresentation/BearingTempColor/Enabled", + "OpenUsdRepresentation/BearingTempColor/Offset", + "OpenUsdRepresentation/BearingTempColor/RenderTargetKind", + "OpenUsdRepresentation/BearingTempColor/Scale", + "OpenUsdRepresentation/BearingTempColor/SignalRole", + "OpenUsdRepresentation/BearingTempColor/SourceNodeId", + "OpenUsdRepresentation/BearingTempColor/TargetPrimPath", + "OpenUsdRepresentation/BearingTempColor/TargetPropertyName", + "OpenUsdRepresentation/BearingTempColor/TargetStage", + "OpenUsdRepresentation/BearingTempColor/TargetUsdTypeName", + "OpenUsdRepresentation/DiffPressureEmissive", + "OpenUsdRepresentation/DiffPressureEmissive/BadQualityAction", + "OpenUsdRepresentation/DiffPressureEmissive/BindingDefinitionId", + "OpenUsdRepresentation/DiffPressureEmissive/Enabled", + "OpenUsdRepresentation/DiffPressureEmissive/RenderTargetKind", + "OpenUsdRepresentation/DiffPressureEmissive/Scale", + "OpenUsdRepresentation/DiffPressureEmissive/SignalRole", + "OpenUsdRepresentation/DiffPressureEmissive/SourceNodeId", + "OpenUsdRepresentation/DiffPressureEmissive/TargetPrimPath", + "OpenUsdRepresentation/DiffPressureEmissive/TargetPropertyName", + "OpenUsdRepresentation/DiffPressureEmissive/TargetStage", + "OpenUsdRepresentation/DiffPressureEmissive/TargetUsdTypeName", + "OpenUsdRepresentation/ImpellerComponent", + "OpenUsdRepresentation/ImpellerComponent/BindingDefinitionId", + "OpenUsdRepresentation/ImpellerComponent/Cardinality", + "OpenUsdRepresentation/ImpellerComponent/ComponentRepresentation", + "OpenUsdRepresentation/ImpellerComponent/CompositionArc", + "OpenUsdRepresentation/ImpellerComponent/Enabled", + "OpenUsdRepresentation/ImpellerComponent/TargetPrimPath", + "OpenUsdRepresentation/PrimPath", + "OpenUsdRepresentation/ShaftSpin", + "OpenUsdRepresentation/ShaftSpin/BadQualityAction", + "OpenUsdRepresentation/ShaftSpin/BindingDefinitionId", + "OpenUsdRepresentation/ShaftSpin/Enabled", + "OpenUsdRepresentation/ShaftSpin/RenderTargetKind", + "OpenUsdRepresentation/ShaftSpin/Scale", + "OpenUsdRepresentation/ShaftSpin/SignalRole", + "OpenUsdRepresentation/ShaftSpin/SourceNodeId", + "OpenUsdRepresentation/ShaftSpin/SourceSemanticId", + "OpenUsdRepresentation/ShaftSpin/TargetPrimPath", + "OpenUsdRepresentation/ShaftSpin/TargetPropertyName", + "OpenUsdRepresentation/ShaftSpin/TargetStage", + "OpenUsdRepresentation/ShaftSpin/TargetUsdTypeName", + "OpenUsdRepresentation/SpeedSetpointCommand", + "OpenUsdRepresentation/SpeedSetpointCommand/BadQualityAction", + "OpenUsdRepresentation/SpeedSetpointCommand/BindingDefinitionId", + "OpenUsdRepresentation/SpeedSetpointCommand/CommandTargetNodeId", + "OpenUsdRepresentation/SpeedSetpointCommand/CommandTriggerPropertyName", + "OpenUsdRepresentation/SpeedSetpointCommand/Enabled", + "OpenUsdRepresentation/SpeedSetpointCommand/Scale", + "OpenUsdRepresentation/SpeedSetpointCommand/SignalRole", + "OpenUsdRepresentation/SpeedSetpointCommand/TargetPrimPath", + "OpenUsdRepresentation/SpeedSetpointCommand/TargetPropertyName", + "OpenUsdRepresentation/SpeedSetpointCommand/TargetStage", + "OpenUsdRepresentation/SpeedSetpointCommand/TargetUsdTypeName", + "OpenUsdRepresentation/Stage", "Operational", "Operational/Measurements", "Operational/Measurements/BearingTemperature", @@ -142,7 +241,9 @@ public sealed class PumpInstanceNodeIdRegressionTests "Operational/Measurements/PumpEfficiency/EngineeringUnits", "Operational/Measurements/PumpPowerInput", "Operational/Measurements/PumpPowerInput/EURange", - "Operational/Measurements/PumpPowerInput/EngineeringUnits" + "Operational/Measurements/PumpPowerInput/EngineeringUnits", + "ShaftAngle", + "SpeedSetpoint" ]; /// diff --git a/tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs b/tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs new file mode 100644 index 0000000000..a4daa0a582 --- /dev/null +++ b/tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs @@ -0,0 +1,747 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.OpenUsd.Client; + +namespace Opc.Ua.Di.Tests +{ + /// + /// End-to-end validation of the draft OPC UA โ€” OpenUSD Bindings companion + /// specification against the PumpDeviceIntegrationServer. Starts the pump + /// server via the generic host, connects a real client session, discovers the + /// OpenUsdRepresentation AddIn + live bindings on Pump #1, subscribes to the + /// bound source Variables, and drives an that + /// converts values and writes them into a โ€” the + /// CI-friendly stand-in for a USD/Omniverse sink. + /// + [TestFixture] + [Category("Pumps")] + [Category("OpenUsd")] + [Category("Integration")] + [NonParallelizable] + public sealed class PumpOpenUsdE2eTests + { + private ITelemetryContext m_telemetry = null!; + private IHost? m_host; + private ISession? m_session; + private ISession? m_privilegedSession; + private ApplicationConfiguration m_clientConfig = null!; + + /// + /// ยง9: the sample withholds the command target's write right by default, so the + /// positive command test must present a real (non-anonymous) credential. This + /// authenticator accepts a single well-known operator credential; the Part 18 + /// role manager then maps it to the AuthenticatedUser role. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", + Justification = "Instantiated by the server's dependency injection via AddIdentityAuthenticator.")] + internal sealed class OperatorAuthenticator : Opc.Ua.Identity.IUserTokenAuthenticator + { + internal const string UserName = "usd-operator"; + internal const string Password = "usd-operator-secret"; + + public UserTokenType TokenType => UserTokenType.UserName; + + public string? IssuedTokenProfileUri => null; + + public ValueTask AuthenticateAsync( + Opc.Ua.Identity.AuthenticationContext context, + CancellationToken ct = default) + { + if (context.TokenHandler is not UserNameIdentityTokenHandler handler) + { + return new ValueTask( + Opc.Ua.Identity.AuthenticationResult.NotHandled); + } + byte[]? password = handler.DecryptedPassword; + if (handler.UserName != UserName || password == null || + System.Text.Encoding.UTF8.GetString(password) != Password) + { + return new ValueTask( + Opc.Ua.Identity.AuthenticationResult.Reject( + new ServiceResult(StatusCodes.BadUserAccessDenied))); + } + return new ValueTask( + Opc.Ua.Identity.AuthenticationResult.Accept(new UserIdentity(handler))); + } + } + + private static int GetFreeTcpPort() + { + var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + int port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + m_telemetry = DefaultTelemetry.Create(b => b.SetMinimumLevel(LogLevel.Warning)); + + int port = GetFreeTcpPort(); + string serverUrl = $"opc.tcp://localhost:{port}/PumpDeviceIntegrationServer"; + + HostApplicationBuilder hostBuilder = Host.CreateApplicationBuilder(); + hostBuilder.Logging.ClearProviders(); + hostBuilder.Logging.SetMinimumLevel(LogLevel.Warning); + hostBuilder.Services + .AddOpcUa() + .AddServer(o => + { + o.ApplicationName = "PumpOpenUsdE2eServer"; + o.ApplicationUri = "urn:localhost:OPCFoundation:PumpOpenUsdE2eServer"; + o.AutoAcceptUntrustedCertificates = true; + o.EndpointUrls.Add(serverUrl); + // ยง9: the pump withholds the command target's write right from + // anonymous sessions, so the endpoint must also offer a real + // credential a connector can present. + o.UserTokenPolicies.Add(new Opc.Ua.Server.Hosting.OpcUaUserTokenPolicy + { + TokenType = UserTokenType.Anonymous + }); + o.UserTokenPolicies.Add(new Opc.Ua.Server.Hosting.OpcUaUserTokenPolicy + { + TokenType = UserTokenType.UserName + }); + }) + .AddIdentityAuthenticator() + .AddNodeManager(); + m_host = hostBuilder.Build(); + await m_host.StartAsync().ConfigureAwait(false); + + string pkiRoot = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "PumpOpenUsdE2e", System.IO.Path.GetRandomFileName()); + m_clientConfig = new ApplicationConfiguration(m_telemetry) + { + ApplicationName = "PumpOpenUsdE2eClient", + ApplicationUri = "urn:localhost:OPCFoundation:PumpOpenUsdE2eClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = System.IO.Path.Combine(pkiRoot, "own"), + SubjectName = "CN=PumpOpenUsdE2eClient, O=OPC Foundation" + }, + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = System.IO.Path.Combine(pkiRoot, "issuer") + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = System.IO.Path.Combine(pkiRoot, "trusted") + }, + RejectedCertificateStore = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = System.IO.Path.Combine(pkiRoot, "rejected") + }, + AutoAcceptUntrustedCertificates = true + }, + TransportQuotas = new TransportQuotas { MaxMessageSize = 4 * 1024 * 1024 }, + ClientConfiguration = new ClientConfiguration(), + ServerConfiguration = new ServerConfiguration() + }; + await m_clientConfig.ValidateAsync(ApplicationType.Client).ConfigureAwait(false); + + var appInstance = new Opc.Ua.Configuration.ApplicationInstance(m_clientConfig, m_telemetry); + await appInstance.CheckApplicationInstanceCertificatesAsync(true).ConfigureAwait(false); + + m_clientConfig.CertificateManager ??= CertificateManagerFactory.Create( + m_clientConfig.SecurityConfiguration, m_telemetry); + m_clientConfig.CertificateManager.AcceptError = static (cert, err) => true; + + // The hosted endpoint opens asynchronously after the host starts. + EndpointDescription? endpointDescription = null; + for (int attempt = 0; attempt < 40; attempt++) + { + try + { + endpointDescription = await CoreClientUtils.SelectEndpointAsync( + m_clientConfig, serverUrl, useSecurity: false, m_telemetry, CancellationToken.None) + .ConfigureAwait(false); + if (endpointDescription != null) + { + break; + } + } + catch (Exception) + { + // not ready yet + } + await Task.Delay(500).ConfigureAwait(false); + } + Assert.That(endpointDescription, Is.Not.Null, "Server endpoint did not become available."); + + var endpoint = new ConfiguredEndpoint( + null, endpointDescription!, EndpointConfiguration.Create(m_clientConfig)); + var sessionFactory = new DefaultSessionFactory(m_telemetry); + m_session = await sessionFactory.CreateAsync( + m_clientConfig, endpoint, updateBeforeConnect: false, + sessionName: "PumpOpenUsdE2e", sessionTimeout: 60000, + identity: new UserIdentity(new AnonymousIdentityToken()), + preferredLocales: default, ct: CancellationToken.None).ConfigureAwait(false); + // A second, authenticated session: ยง9 requires a connector to hold the + // authorization the command target demands, and the sample withholds it + // from anonymous sessions. + m_privilegedSession = await sessionFactory.CreateAsync( + m_clientConfig, endpoint, updateBeforeConnect: false, + sessionName: "PumpOpenUsdE2ePrivileged", sessionTimeout: 60000, + identity: new UserIdentity( + OperatorAuthenticator.UserName, + System.Text.Encoding.UTF8.GetBytes(OperatorAuthenticator.Password)), + preferredLocales: default, ct: CancellationToken.None).ConfigureAwait(false); + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + if (m_session != null) + { + await m_session.CloseAsync(CancellationToken.None).ConfigureAwait(false); + await m_session.DisposeAsync().ConfigureAwait(false); + m_session = null; + } + if (m_privilegedSession != null) + { + await m_privilegedSession.CloseAsync(CancellationToken.None).ConfigureAwait(false); + await m_privilegedSession.DisposeAsync().ConfigureAwait(false); + m_privilegedSession = null; + } + if (m_clientConfig?.CertificateManager is IDisposable manager) + { + manager.Dispose(); + } + if (m_host != null) + { + await m_host.StopAsync().ConfigureAwait(false); + m_host.Dispose(); + m_host = null; + } + } + + [Test] + public void OpenUsdCompanionModelIsDeployedAndServed() + { + // The running server advertises the OpenUSD namespace ... + int ns = m_session!.NamespaceUris.GetIndex("http://opcfoundation.org/UA/OpenUSD/"); + Assert.That(ns, Is.GreaterThan(0), "OpenUSD namespace not advertised by the server."); + + // ... and serves the companion type nodes (proves the NodeSet loaded). + // Resolved from the generated symbolic constant (an ExpandedNodeId carrying + // the namespace URI) rather than a numeric literal, so a model renumbering + // can never silently invalidate this assertion. + NodeId repType = ExpandedNodeId.ToNodeId( + Opc.Ua.OpenUsd.ObjectTypeIds.OpenUsdRepresentationType, m_session!.NamespaceUris); + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + string bn = connector.ReadBrowseNameAsync(repType, CancellationToken.None) + .GetAwaiter().GetResult(); + Assert.That(bn, Is.EqualTo("OpenUsdRepresentationType")); + } + + [Test] + public async Task OpenUsdFacilityIsBrowsableFromServerObjectAsync() + { + // F1 regression: the well-known OpenUSD facility must be a browsable + // component of the Server Object (i=2253), so a spec-conformant connector + // can Browse Server -> OpenUSD -> Representations without hard-coding NodeIds. + var browseServer = new BrowseDescription + { + NodeId = Opc.Ua.ObjectIds.Server, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All + }; + BrowseResponse serverChildren = await m_session!.BrowseAsync( + null!, null!, 0, new BrowseDescription[] { browseServer }, CancellationToken.None) + .ConfigureAwait(false); + + ReferenceDescription? openUsd = null; + ArrayOf refs = serverChildren.Results[0].References; + for (int i = 0; i < refs.Count; i++) + { + if (refs[i].BrowseName.Name == "OpenUSD") + { + openUsd = refs[i]; + break; + } + } + Assert.That(openUsd, Is.Not.Null, "OpenUSD facility is not browsable from the Server Object."); + + // ... and Representations is reachable one hop below it. + NodeId openUsdId = ExpandedNodeId.ToNodeId(openUsd!.NodeId, m_session!.NamespaceUris); + var browseRoot = new BrowseDescription + { + NodeId = openUsdId, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All + }; + BrowseResponse rootChildren = await m_session!.BrowseAsync( + null!, null!, 0, new BrowseDescription[] { browseRoot }, CancellationToken.None) + .ConfigureAwait(false); + bool hasRepresentations = false; + ArrayOf rootRefs = rootChildren.Results[0].References; + for (int i = 0; i < rootRefs.Count; i++) + { + if (rootRefs[i].BrowseName.Name == "Representations") + { + hasRepresentations = true; + break; + } + } + Assert.That(hasRepresentations, Is.True, + "Representations registry is not reachable from Server/OpenUSD."); + } + + [Test] + public async Task RepresentationAndBindingsAreDiscoverableAsync() + { + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + OpenUsdConnector.RepresentationInfo? rep = await PumpRepAsync(connector).ConfigureAwait(false); + + Assert.That(rep, Is.Not.Null, "OpenUsdRepresentation not discovered on Pump #1."); + Assert.That(rep!.PrimPath, Is.EqualTo("/Plant/Pumps/P101")); + Assert.That(rep.StageNodeId.IsNull, Is.False); + Assert.That(rep.RootLayerIdentifier, Is.EqualTo("asset-repo/Plant.usd")); + // 0.1 telemetry (3) + 0.2 alarm (1) + 0.2 command (1) = 5 bindings. + Assert.That(rep.Bindings, Has.Count.EqualTo(5)); + } + + [Test] + public async Task SemanticIdAndSignalRoleAreSurfacedAsync() + { + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + OpenUsdConnector.RepresentationInfo? rep = await PumpRepAsync(connector).ConfigureAwait(false); + Assert.That(rep, Is.Not.Null); + + OpenUsdConnector.BindingInfo? massFlow = null; + OpenUsdConnector.BindingInfo? command = null; + foreach (OpenUsdConnector.BindingInfo b in rep!.Bindings) + { + if (b.SourceSemanticId == "0173-1#02-AAO677#002") + { + massFlow = b; + } + if (b.Intent == OpenUsdIntentProfile.UsdToUaCommand) + { + command = b; + } + } + + Assert.Multiple(() => + { + // Semantic-ID source: the flow binding carries a portable IRDI. + Assert.That(massFlow, Is.Not.Null, "MassFlow binding has no SourceSemanticId."); + Assert.That(massFlow!.SignalRole, Is.EqualTo(OpenUsdSignalRole.Observable)); + // Controllable/command: a UsdToUaCommand binding is declared and marked Controllable. + Assert.That(command, Is.Not.Null, "No UsdToUaCommand binding discovered."); + Assert.That(command!.SignalRole, Is.EqualTo(OpenUsdSignalRole.Controllable)); + Assert.That(command.CommandTargetNodeId.IsNull, Is.False); + }); + } + + [Test] + public async Task StageRootLayerDigestVerifiesAsync() + { + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + OpenUsdConnector.RepresentationInfo? rep = await PumpRepAsync(connector).ConfigureAwait(false); + Assert.That(rep, Is.Not.Null); + + // ยง5.2: the digest is computed over the resolved root-layer *content*, not + // over the identifier string, and a connector shall refuse to open a layer + // whose digest does not match. + Assert.That(rep!.DigestAlgorithm, Is.EqualTo(OpenUsdDigestAlgorithm.Sha256)); + Assert.That(rep!.RootLayerDigest.IsNull, Is.False); + Assert.That(rep!.RootLayerDigest.Length, Is.EqualTo(32)); + bool verified = await connector + .VerifyStageDigestAsync(rep!, CancellationToken.None).ConfigureAwait(false); + Assert.That(verified, Is.True, "RootLayerDigest failed verification."); + } + + [Test] + public async Task StageRootLayerDigestRefusesCorruptedContentAsync() + { + // ยง5.2 negative: a connector shall refuse to open a layer whose digest does + // not match the resolved content. Flipping a single byte of the served root + // layer must be detected โ€” which is only possible because the digest covers + // the content and not the (unchanged) identifier string. + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + OpenUsdConnector.RepresentationInfo? rep = await PumpRepAsync(connector).ConfigureAwait(false); + Assert.That(rep, Is.Not.Null); + + byte[] corrupted = System.Text.Encoding.UTF8.GetBytes("#usda 1.0\n(tampered)\n"); + Assert.That(OpenUsdConnector.VerifyStageDigest(rep!, corrupted), Is.False, + "Connector accepted a root layer whose content does not match the digest."); + } + + [Test] + public async Task AlarmBindingDrivesUsdVisibilityAsync() + { + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + await Task.Delay(2000, CancellationToken.None).ConfigureAwait(false); + await connector.StopAsync().ConfigureAwait(false); + + // The UaAlarmToUsd binding subscribes the alarm-active aspect and authors + // the status-light visibility token (initially "invisible" until an alarm). + Assert.That(sink.WasWritten("/Plant/Pumps/P101/StatusLight", "visibility"), Is.True, + "Alarm binding did not author StatusLight visibility."); + } + + [Test] + public void CommandBindingIsFailClosedByDefault() + { + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + // Opt-in: with commands disabled (the default), actuation is refused. + Assert.ThrowsAsync(async () => + await connector.IssueCommandAsync(10.0, CancellationToken.None).ConfigureAwait(false)); + } + + [Test] + public async Task CommandBindingWritesServerVariableWhenEnabledAsync() + { + var connector = new OpenUsdConnector( + m_privilegedSession!, new MockUsdSink(), enableCommands: true); + OpenUsdConnector.RepresentationInfo? rep = await PumpRepAsync(connector).ConfigureAwait(false); + NodeId target = NodeId.Null; + foreach (OpenUsdConnector.BindingInfo b in rep!.Bindings) + { + if (b.Intent == OpenUsdIntentProfile.UsdToUaCommand) + { + target = b.CommandTargetNodeId; + } + } + Assert.That(target.IsNull, Is.False, "Command target NodeId missing."); + + const double setpoint = 42.5; + bool ok = await connector.IssueCommandAsync(setpoint, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(ok, Is.True, "Command write did not succeed."); + + var toRead = new ReadValueId[] + { + new ReadValueId { NodeId = target, AttributeId = Attributes.Value } + }; + ReadResponse rr = await m_privilegedSession!.ReadAsync( + null!, 0, TimestampsToReturn.Neither, toRead, CancellationToken.None) + .ConfigureAwait(false); + double actual = System.Convert.ToDouble( + rr.Results[0].WrappedValue.AsBoxedObject(), + System.Globalization.CultureInfo.InvariantCulture); + Assert.That(actual, Is.EqualTo(setpoint).Within(1e-9), + "Server SpeedSetpoint was not updated by the command binding."); + } + + [Test] + public async Task CommandIsRefusedForUnauthorizedSessionAsync() + { + // ยง5.10/ยง9: the connector shall hold the write authorization the target + // requires, which the Server withholds by default. The anonymous session + // does not hold it, so the connector refuses *before* issuing the Write โ€” + // it must not rely on the Server's error, and the value must not change. + var connector = new OpenUsdConnector(m_session!, new MockUsdSink(), enableCommands: true); + OpenUsdConnector.RepresentationInfo? rep = await PumpRepAsync(connector).ConfigureAwait(false); + NodeId target = NodeId.Null; + foreach (OpenUsdConnector.BindingInfo b in rep!.Bindings) + { + if (b.Intent == OpenUsdIntentProfile.UsdToUaCommand) + { + target = b.CommandTargetNodeId; + } + } + Assert.That(target.IsNull, Is.False, "Command target NodeId missing."); + + var toRead = new ReadValueId[] + { + new ReadValueId { NodeId = target, AttributeId = Attributes.Value } + }; + ReadResponse before = await m_session!.ReadAsync( + null!, 0, TimestampsToReturn.Neither, toRead, CancellationToken.None) + .ConfigureAwait(false); + double previous = System.Convert.ToDouble( + before.Results[0].WrappedValue.AsBoxedObject(), + System.Globalization.CultureInfo.InvariantCulture); + + bool ok = await connector.IssueCommandAsync(previous + 7.0, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(ok, Is.False, "Command was issued without the required authorization."); + + ReadResponse after = await m_session!.ReadAsync( + null!, 0, TimestampsToReturn.Neither, toRead, CancellationToken.None) + .ConfigureAwait(false); + double actual = System.Convert.ToDouble( + after.Results[0].WrappedValue.AsBoxedObject(), + System.Globalization.CultureInfo.InvariantCulture); + Assert.That(actual, Is.EqualTo(previous).Within(1e-9), + "Unauthorized command changed the server value."); + } + + [Test] + public async Task HistoryReplayDegradesGracefullyOnNonHistorizingSourceAsync() + { + // The demo pump does not historize, so history replay finds no + // UaHistoryToUsd binding and returns 0 without throwing. This validates + // the connector's Part 11 code path and documents the requirement that a + // history binding needs a historizing source (spec finding). + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + int authored = await connector.ReplayHistoryAsync( + DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(authored, Is.Zero); + } + + [Test] + public async Task LiveValuesFlowThroughConnectorToUsdSinkAsync() + { + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + await Task.Delay(4000, CancellationToken.None).ConfigureAwait(false); + await connector.StopAsync().ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(sink.WasWritten("/Plant/Pumps/P101/Impeller", "xformOp:rotateZ"), Is.True, + "Rotation binding produced no value."); + Assert.That(sink.WasWritten("/Plant/Pumps/P101/Body/Mat/Surface", "inputs:diffuseColor"), Is.True, + "DisplayColor binding produced no value."); + Assert.That(sink.WasWritten("/Plant/Pumps/P101/StatusLight/Mat/Surface", "inputs:emissiveColor"), Is.True, + "EmissiveColor binding produced no value."); + Assert.That(sink.TotalWrites, Is.GreaterThan(0)); + }); + } + + [Test] + public async Task PumpComponentsComposeChildPrimsAsync() + { + // 1:1 composition (ยง5.12): the pump is composed of an Impeller and a Bearing, + // each mapped to a child prim (arc=Child). + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + Assert.Multiple(() => + { + Assert.That(sink.WasPrimComposed("/Plant/Pumps/P101/Impeller"), Is.True, + "Impeller component prim not composed."); + Assert.That(sink.WasPrimComposed("/Plant/Pumps/P101/Bearing"), Is.True, + "Bearing component prim not composed."); + }); + } + finally + { + await connector.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task ProductionLineAggregatesPumpsAsync() + { + // 1..n composition (ยง5.12): a ProductionLine aggregates its pumps as + // instanceable reference prims under a Pumps scope. + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + Assert.Multiple(() => + { + Assert.That(sink.WasPrimComposed("/Plant/Line1/Pumps/P_201"), Is.True, + "Aggregated pump P-201 prim not composed."); + Assert.That(sink.WasPrimComposed("/Plant/Line1/Pumps/P_202"), Is.True, + "Aggregated pump P-202 prim not composed."); + Assert.That(sink.IsPrimActive("/Plant/Line1/Pumps/P_201"), Is.True); + }); + } + finally + { + await connector.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task CrossServerComponentIsComposedAsync() + { + // Cross-server composition (ยง5.14): the Line declares a component on another + // server; the connector composes its reference prim (federation drives its + // bindings only when a remote session factory is supplied โ€” not here). + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + Assert.That(sink.WasPrimComposed("/Plant/Line1/RemotePump"), Is.True, + "Cross-server component prim not composed."); + } + finally + { + await connector.StopAsync().ConfigureAwait(false); + } + } + + [Test] + [Ignore("Blocked by OPCFoundation/UA-.NETStandard#4061: a runtime-added child is " + + "intermittently missing from a client Browse after CreateNodeAsync/AddNodeAsync + " + + "GeneralModelChange, so the connector does not reliably observe the dynamically added " + + "pump. Server-side state is correct; the browse is intermittently served stale. " + + "Re-enable once the upstream browse-consistency issue is fixed.")] + public async Task DynamicPumpIsComposedThenDeactivatedAsync() + { + // Dynamic composition (ยง5.13): the server periodically adds then removes a + // pump, emitting model-change events; the connector reconciles the prim + // (composed active on add, active=false on remove). + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + const string dyn = "/Plant/Line1/Pumps/P_203"; + bool appeared = await PollAsync( + () => sink.WasPrimComposed(dyn) && sink.IsPrimActive(dyn), TimeSpan.FromSeconds(20)) + .ConfigureAwait(false); + Assert.That(appeared, Is.True, "Dynamically added pump prim was not composed."); + + bool deactivated = await PollAsync( + () => sink.WasPrimComposed(dyn) && !sink.IsPrimActive(dyn), TimeSpan.FromSeconds(20)) + .ConfigureAwait(false); + Assert.That(deactivated, Is.True, "Dynamically removed pump prim was not deactivated."); + } + finally + { + await connector.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task ComponentBindingsAreDiscoverableAsync() + { + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + System.Collections.Generic.List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None).ConfigureAwait(false); + + OpenUsdConnector.RepresentationInfo? pump = reps.Find(r => r.PrimPath == "/Plant/Pumps/P101"); + OpenUsdConnector.RepresentationInfo? line = reps.Find(r => r.PrimPath == "/Plant/Line1"); + + Assert.Multiple(() => + { + Assert.That(reps, Has.Count.GreaterThanOrEqualTo(2), "Expected pump + line representations."); + Assert.That(pump, Is.Not.Null); + Assert.That(pump!.Components, Has.Count.EqualTo(2), "Pump should have 2 (1:1) component bindings."); + Assert.That(line, Is.Not.Null); + // Line: a Many aggregation + a cross-server component. + Assert.That(line!.Components.Exists(c => c.Cardinality == OpenUsdCardinality.Many), Is.True); + Assert.That(line.Components.Exists(c => !string.IsNullOrEmpty(c.ComponentEndpointUrl)), Is.True); + }); + } + + private static async Task PumpRepAsync(OpenUsdConnector connector) + { + System.Collections.Generic.List all = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None).ConfigureAwait(false); + return all.Find(r => r.PrimPath == "/Plant/Pumps/P101"); + } + + private static async Task PollAsync(Func condition, TimeSpan timeout) + { + DateTime deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return true; + } + await Task.Delay(250).ConfigureAwait(false); + } + return condition(); + } + + [Test] + public async Task ServedAssetsAreFetchedVerifiedAndCachedAsync() + { + // ยง5.15 asset content delivery: the server serves its USD layer closure + // (Plant.usda RootLayer + pump.usda + remote-pump.usda) via Part 5 FileType; + // the connector streams, verifies each digest, and caches them locally. + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + string cacheDir = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "PumpAssetCache", System.IO.Path.GetRandomFileName()); + try + { + System.Collections.Generic.List assets = + await connector.FetchServedAssetsAsync(cacheDir, CancellationToken.None).ConfigureAwait(false); + + OpenUsdConnector.FetchedAsset? root = assets.Find(a => a.Kind == OpenUsdAssetKind.RootLayer); + Assert.Multiple(() => + { + Assert.That(assets, Has.Count.EqualTo(3), "Expected 3 served layers."); + Assert.That(assets.Exists(a => a.Identifier == "Plant.usda" + && a.Kind == OpenUsdAssetKind.RootLayer), Is.True, "Plant.usda RootLayer not served."); + Assert.That(assets.Exists(a => a.Identifier == "pump.usda"), Is.True, "pump.usda not served."); + Assert.That(assets.Exists(a => a.Identifier == "remote-pump.usda"), Is.True, + "remote-pump.usda not served."); + Assert.That(assets.TrueForAll(a => a.DigestVerified), Is.True, + "A served layer failed digest verification."); + Assert.That(assets.TrueForAll(a => + System.IO.File.Exists(a.LocalPath) && new System.IO.FileInfo(a.LocalPath).Length > 0), + Is.True, "A served layer was not cached to disk."); + Assert.That(root, Is.Not.Null, "No RootLayer served."); + Assert.That(System.IO.File.ReadAllText(root!.LocalPath).TrimStart(), + Does.StartWith("#usda"), "Cached root layer is not valid USD text."); + }); + } + finally + { + if (System.IO.Directory.Exists(cacheDir)) + { + System.IO.Directory.Delete(cacheDir, recursive: true); + } + } + } + } +} diff --git a/tests/Opc.Ua.Di.Tests/RobotMobilityTests.cs b/tests/Opc.Ua.Di.Tests/RobotMobilityTests.cs new file mode 100644 index 0000000000..92f59b7c8b --- /dev/null +++ b/tests/Opc.Ua.Di.Tests/RobotMobilityTests.cs @@ -0,0 +1,218 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Gpos; +using Opc.Ua.Positioning; +using Opc.Ua.Positioning.Server; + +namespace Opc.Ua.Di.Tests +{ + [TestFixture] + [Category("Robotics")] + [Category("Positioning")] + public sealed class RobotMobilityTests + { + [Test] + public void FixedModeUsesConfiguredPose() + { + global::Robotics.MobileRobotPositionOptions options = CreateOptions(); + options.R1.Mode = global::Robotics.RobotMotionMode.Fixed; + options.R1.OriginX = 4.0; + options.R1.OriginY = -3.0; + options.R1.OriginZ = 2.0; + options.R1.FixedHeadingDegrees = 35.0; + + ThreeDFrame frame = CreateProvider(options).EvaluateLocalFrame( + "R1", + TimeSpan.FromSeconds(123)); + + Assert.Multiple(() => + { + Assert.That(frame.CartesianCoordinates.X, Is.EqualTo(4.0)); + Assert.That(frame.CartesianCoordinates.Y, Is.EqualTo(-3.0)); + Assert.That(frame.CartesianCoordinates.Z, Is.EqualTo(2.0)); + Assert.That(frame.Orientation.C, Is.EqualTo(35.0)); + }); + } + + [Test] + public void FigureEightModeFollowsConfiguredAmplitudes() + { + global::Robotics.MobileRobotPositionOptions options = CreateOptions(); + options.R1.Mode = global::Robotics.RobotMotionMode.FigureEight; + options.R1.OriginX = 1.0; + options.R1.OriginY = 2.0; + options.R1.AmplitudeX = 0.6; + options.R1.AmplitudeY = 0.4; + options.R1.PeriodSeconds = 20.0; + + ThreeDFrame frame = CreateProvider(options).EvaluateLocalFrame( + "R1", + TimeSpan.FromSeconds(5.0)); + + Assert.Multiple(() => + { + Assert.That(frame.CartesianCoordinates.X, Is.EqualTo(1.6).Within(1e-12)); + Assert.That(frame.CartesianCoordinates.Y, Is.EqualTo(2.0).Within(1e-12)); + Assert.That( + !double.IsNaN(frame.Orientation.C) && + !double.IsInfinity(frame.Orientation.C), + Is.True); + }); + } + + [Test] + public void CircleModeUsesRadiusAndTangentHeading() + { + global::Robotics.MobileRobotPositionOptions options = CreateOptions(); + options.R1.Mode = global::Robotics.RobotMotionMode.Circle; + options.R1.Radius = 0.75; + options.R1.PeriodSeconds = 8.0; + + ThreeDFrame frame = CreateProvider(options).EvaluateLocalFrame( + "R1", + TimeSpan.Zero); + + Assert.Multiple(() => + { + Assert.That( + frame.CartesianCoordinates.X, + Is.EqualTo(options.R1.OriginX + 0.75).Within(1e-12)); + Assert.That( + frame.CartesianCoordinates.Y, + Is.EqualTo(options.R1.OriginY).Within(1e-12)); + Assert.That(frame.Orientation.C, Is.EqualTo(90.0).Within(1e-12)); + }); + } + + [Test] + public void ShuttleModeReversesHeadingWithDirection() + { + global::Robotics.MobileRobotPositionOptions options = CreateOptions(); + options.R1.Mode = global::Robotics.RobotMotionMode.Shuttle; + options.R1.ShuttleDistance = 1.0; + options.R1.PeriodSeconds = 4.0; + + global::Robotics.MobileRobotPositionProvider provider = + CreateProvider(options); + ThreeDFrame forward = provider.EvaluateLocalFrame( + "R1", + TimeSpan.Zero); + ThreeDFrame reverse = provider.EvaluateLocalFrame( + "R1", + TimeSpan.FromSeconds(2.0)); + + Assert.Multiple(() => + { + Assert.That(forward.Orientation.C, Is.Zero.Within(1e-12)); + Assert.That( + Math.Abs(reverse.Orientation.C), + Is.EqualTo(180.0).Within(1e-12)); + }); + } + + [Test] + public void RobotsUseIndependentModesAndOrigins() + { + global::Robotics.MobileRobotPositionOptions options = CreateOptions(); + options.R1.Mode = global::Robotics.RobotMotionMode.Fixed; + options.R2.Mode = global::Robotics.RobotMotionMode.Circle; + options.R2.Radius = 0.25; + + global::Robotics.MobileRobotPositionProvider provider = + CreateProvider(options); + ThreeDFrame r1 = provider.EvaluateLocalFrame("R1", TimeSpan.Zero); + ThreeDFrame r2 = provider.EvaluateLocalFrame("R2", TimeSpan.Zero); + + Assert.Multiple(() => + { + Assert.That(r1.CartesianCoordinates.X, Is.EqualTo(-1.2)); + Assert.That(r2.CartesianCoordinates.X, Is.Not.EqualTo(r1.CartesianCoordinates.X)); + Assert.That(r2.CartesianCoordinates.X, Is.EqualTo(1.45).Within(1e-12)); + }); + } + + [Test] + public async Task GlobalSamplePreservesElevationAndLocalHeightAsync() + { + global::Robotics.MobileRobotPositionProvider provider = + CreateProvider(CreateOptions()); + GeoLocationSample sample = await provider.ReadAsync( + "R1", + CancellationToken.None).ConfigureAwait(false); + GeoPosition position = sample.Position!.Value; + var geographic = new S3DGeographicCoordinateDataType + { + EncodingMask = position.Height.HasValue + ? (uint)S3DGeographicCoordinateDataTypeFields.Elevation + : 0, + Latitude = position.Latitude, + Longitude = position.Longitude, + Elevation = position.Height ?? 0.0 + }; + ThreeDCartesianCoordinates local = provider.Scenario.Fit.GlobalToLocal( + geographic, + AngleUnit.Degrees); + + Assert.Multiple(() => + { + Assert.That(position.Height, Is.Not.Null); + Assert.That(local.Z, Is.Zero.Within(1e-5)); + }); + } + + private static global::Robotics.MobileRobotPositionOptions CreateOptions() + { + return new global::Robotics.MobileRobotPositionOptions + { + R1 = new global::Robotics.RobotMotionOptions + { + OriginX = -1.2 + }, + R2 = new global::Robotics.RobotMotionOptions + { + OriginX = 1.2 + } + }; + } + + private static global::Robotics.MobileRobotPositionProvider CreateProvider( + global::Robotics.MobileRobotPositionOptions options) + { + return new global::Robotics.MobileRobotPositionProvider( + options, + new global::Robotics.RobotPositioningScenario(), + TimeProvider.System); + } + } +} diff --git a/tests/Opc.Ua.Di.Tests/RobotOpenUsdE2eTests.cs b/tests/Opc.Ua.Di.Tests/RobotOpenUsdE2eTests.cs new file mode 100644 index 0000000000..21ff71d280 --- /dev/null +++ b/tests/Opc.Ua.Di.Tests/RobotOpenUsdE2eTests.cs @@ -0,0 +1,1286 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.Client.Subscriptions; +using Opc.Ua.Client.Subscriptions.Streaming; +using Opc.Ua.Gpos; +using Opc.Ua.OpenUsd.Client; +using Opc.Ua.Positioning; +using Opc.Ua.Positioning.Client; +using Opc.Ua.Positioning.Server.Hosting; +using Opc.Ua.Robotics.Server; +using StreamingMonitoredItemOptions = + Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions; + +namespace Opc.Ua.Di.Tests +{ + /// + /// End-to-end validation of the draft OPC UA โ€” OpenUSD Bindings companion + /// specification against the MinimalRobotServer. Starts the robotics + /// server via the generic host, connects a real client session, discovers the 15 + /// OpenUsdRepresentation AddIns (a MotionDeviceSystem "RobotCell" of two 6-axis + /// robots composed recursively of their axes), subscribes to the bound Axis + /// positions, and drives the SAME generic used by + /// the pump sample into a โ€” proving the connector needs + /// no robot-specific code. Asserts per-axis articulation, nested composition + /// (system โ†’ devices โ†’ axes), 1..n robot aggregation, an emergency-stop safety + /// visual, an opt-in speed-override command, and dynamic tool composition. + /// + [TestFixture] + [Category("Robotics")] + [Category("OpenUsd")] + [Category("Integration")] + [NonParallelizable] + public sealed class RobotOpenUsdE2eTests + { + private ITelemetryContext m_telemetry = null!; + private IHost? m_host; + private ISession? m_session; + private ISession? m_privilegedSession; + private ApplicationConfiguration m_clientConfig = null!; + + /// + /// ยง9: the robot sample withholds the command target's write right from + /// anonymous sessions, so the positive command test must present a real + /// credential. The Part 18 role manager then maps it to AuthenticatedUser. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812", + Justification = "Instantiated by the server's dependency injection via AddIdentityAuthenticator.")] + internal sealed class OperatorAuthenticator : Opc.Ua.Identity.IUserTokenAuthenticator + { + internal const string UserName = "usd-operator"; + internal const string Password = "usd-operator-secret"; + + public UserTokenType TokenType => UserTokenType.UserName; + + public string? IssuedTokenProfileUri => null; + + public ValueTask AuthenticateAsync( + Opc.Ua.Identity.AuthenticationContext context, + CancellationToken ct = default) + { + if (context.TokenHandler is not UserNameIdentityTokenHandler handler) + { + return new ValueTask( + Opc.Ua.Identity.AuthenticationResult.NotHandled); + } + byte[]? password = handler.DecryptedPassword; + if (handler.UserName != UserName || password == null || + System.Text.Encoding.UTF8.GetString(password) != Password) + { + return new ValueTask( + Opc.Ua.Identity.AuthenticationResult.Reject( + new ServiceResult(StatusCodes.BadUserAccessDenied))); + } + return new ValueTask( + Opc.Ua.Identity.AuthenticationResult.Accept(new UserIdentity(handler))); + } + } + + private const string CellPrim = "/Cell"; + private const string R1Prim = "/Cell/Robots/R1"; + private const string R2Prim = "/Cell/Robots/R2"; + private const string ToolPrim = "/Cell/Robots/R1/Base/J1/J2/J3/J4/J5/J6/Flange/Tool"; + + private static int GetFreeTcpPort() + { + var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + int port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + m_telemetry = DefaultTelemetry.Create(b => b.SetMinimumLevel(LogLevel.Warning)); + + int port = GetFreeTcpPort(); + string serverUrl = $"opc.tcp://localhost:{port}/MinimalRobotServer"; + + HostApplicationBuilder hostBuilder = Host.CreateApplicationBuilder(); + hostBuilder.Logging.ClearProviders(); + hostBuilder.Logging.SetMinimumLevel(LogLevel.Warning); + hostBuilder.Services.AddOptions(); + hostBuilder.Services.AddSingleton(); + IPositioningServerBuilder positioning = hostBuilder.Services + .AddOpcUa() + .AddServer(o => + { + o.ApplicationName = "RobotOpenUsdE2eServer"; + o.ApplicationUri = "urn:localhost:OPCFoundation:RobotOpenUsdE2eServer"; + o.AutoAcceptUntrustedCertificates = true; + o.EndpointUrls.Add(serverUrl); + // ยง9: the robot withholds the command target's write right from + // anonymous sessions, so the endpoint must also offer a credential. + o.UserTokenPolicies.Add(new Opc.Ua.Server.Hosting.OpcUaUserTokenPolicy + { + TokenType = UserTokenType.Anonymous + }); + o.UserTokenPolicies.Add(new Opc.Ua.Server.Hosting.OpcUaUserTokenPolicy + { + TokenType = UserTokenType.UserName + }); + }) + .AddIdentityAuthenticator() + .AddRobotics() + .AddRoboticsModel() + .AddRoboticsModel() + .AddRoboticsModel() + .ConfigureRobotics() + .ConfigureRobotics(async context => + await context.GetRequiredService() + .RunAsync(context.Manager, context.CancellationToken).ConfigureAwait(false)) + .AddPositioningFor(); + positioning + .AddGeoLocationProvider() + .ConfigurePositioningFor( + context => global::Robotics.RobotCell.GetForManager(context.Manager) + .ConfigurePositioningAsync(context)); + m_host = hostBuilder.Build(); + await m_host.StartAsync().ConfigureAwait(false); + + string pkiRoot = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "RobotOpenUsdE2e", System.IO.Path.GetRandomFileName()); + m_clientConfig = new ApplicationConfiguration(m_telemetry) + { + ApplicationName = "RobotOpenUsdE2eClient", + ApplicationUri = "urn:localhost:OPCFoundation:RobotOpenUsdE2eClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = System.IO.Path.Combine(pkiRoot, "own"), + SubjectName = "CN=RobotOpenUsdE2eClient, O=OPC Foundation" + }, + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = System.IO.Path.Combine(pkiRoot, "issuer") + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = System.IO.Path.Combine(pkiRoot, "trusted") + }, + RejectedCertificateStore = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = System.IO.Path.Combine(pkiRoot, "rejected") + }, + AutoAcceptUntrustedCertificates = true + }, + TransportQuotas = new TransportQuotas { MaxMessageSize = 4 * 1024 * 1024 }, + ClientConfiguration = new ClientConfiguration(), + ServerConfiguration = new ServerConfiguration() + }; + await m_clientConfig.ValidateAsync(ApplicationType.Client).ConfigureAwait(false); + + var appInstance = new Configuration.ApplicationInstance(m_clientConfig, m_telemetry); + await appInstance.CheckApplicationInstanceCertificatesAsync(true).ConfigureAwait(false); + + m_clientConfig.CertificateManager ??= CertificateManagerFactory.Create( + m_clientConfig.SecurityConfiguration, m_telemetry); + m_clientConfig.CertificateManager.AcceptError = static (cert, err) => true; + + EndpointDescription? endpointDescription = null; + for (int attempt = 0; attempt < 90; attempt++) + { + try + { + endpointDescription = await CoreClientUtils.SelectEndpointAsync( + m_clientConfig, serverUrl, useSecurity: false, m_telemetry, CancellationToken.None) + .ConfigureAwait(false); + if (endpointDescription != null) + { + break; + } + } + catch (Exception) + { + // not ready yet + } + await Task.Delay(500).ConfigureAwait(false); + } + Assert.That(endpointDescription, Is.Not.Null, "Server endpoint did not become available."); + + var endpoint = new ConfiguredEndpoint( + null, endpointDescription!, EndpointConfiguration.Create(m_clientConfig)); + var sessionFactory = new DefaultSessionFactory(m_telemetry); + m_session = await sessionFactory.CreateAsync( + m_clientConfig, endpoint, updateBeforeConnect: false, + sessionName: "RobotOpenUsdE2e", sessionTimeout: 60000, + identity: new UserIdentity(new AnonymousIdentityToken()), + preferredLocales: default, ct: CancellationToken.None).ConfigureAwait(false); + // A second, authenticated session: ยง9 requires the connector to hold the + // authorization the command target demands. + m_privilegedSession = await sessionFactory.CreateAsync( + m_clientConfig, endpoint, updateBeforeConnect: false, + sessionName: "RobotOpenUsdE2ePrivileged", sessionTimeout: 60000, + identity: new UserIdentity( + OperatorAuthenticator.UserName, + System.Text.Encoding.UTF8.GetBytes(OperatorAuthenticator.Password)), + preferredLocales: default, ct: CancellationToken.None).ConfigureAwait(false); + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + if (m_session != null) + { + await m_session.CloseAsync(CancellationToken.None).ConfigureAwait(false); + await m_session.DisposeAsync().ConfigureAwait(false); + m_session = null; + } + if (m_privilegedSession != null) + { + await m_privilegedSession.CloseAsync(CancellationToken.None).ConfigureAwait(false); + await m_privilegedSession.DisposeAsync().ConfigureAwait(false); + m_privilegedSession = null; + } + if (m_clientConfig?.CertificateManager is IDisposable manager) + { + manager.Dispose(); + } + if (m_host != null) + { + await m_host.StopAsync().ConfigureAwait(false); + m_host.Dispose(); + m_host = null; + } + } + + private static async Task PollAsync(Func condition, TimeSpan timeout) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (condition()) + { + return true; + } + await Task.Delay(250).ConfigureAwait(false); + } + return condition(); + } + + private static async Task ReadSingleAsync( + IAsyncEnumerable source) + { + await foreach (T value in source.ConfigureAwait(false)) + { + return value; + } + throw new InvalidOperationException( + "The test streaming subscription produced no value."); + } + + private sealed class SingleValueStreamingSubscription : + IStreamingSubscription + { + private readonly DataValue m_value; + + public SingleValueStreamingSubscription(DataValue value) + { + m_value = value; + } + + public IAsyncEnumerable SubscribeDataChangesAsync( + NodeId nodeId, + StreamingMonitoredItemOptions? options = null, + CancellationToken ct = default) + { + return YieldDataChangeAsync(ct); + } + + public IAsyncEnumerable SubscribeDataChangesAsync( + IReadOnlyList nodeIds, + StreamingMonitoredItemOptions? options = null, + CancellationToken ct = default) + { + return YieldDataChangeAsync(ct); + } + + public IAsyncEnumerable SubscribeEventsAsync( + NodeId notifierId, + EventFilter filter, + StreamingMonitoredItemOptions? options = null, + CancellationToken ct = default) + { + return EmptyEventsAsync(ct); + } + + public ValueTask DisposeAsync() + { + return default; + } + + private async IAsyncEnumerable + YieldDataChangeAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return new DataValueChange(null, m_value, null); + } + + private static async IAsyncEnumerable + EmptyEventsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield break; + } + } + + private Task> AllRepsAsync(OpenUsdConnector connector) + { + return connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + } + + private async Task> BrowseAsync( + NodeId nodeId, + NodeId referenceTypeId, + uint nodeClassMask = 0, + BrowseDirection browseDirection = BrowseDirection.Forward) + { + ( + ArrayOf> results, + ArrayOf errors + ) = await m_session!.ManagedBrowseAsync( + requestHeader: null, + view: null, + nodesToBrowse: [nodeId], + maxResultsToReturn: 0, + browseDirection: browseDirection, + referenceTypeId: referenceTypeId, + includeSubtypes: true, + nodeClassMask: nodeClassMask, + ct: CancellationToken.None).ConfigureAwait(false); + for (int i = 0; i < errors.Count; i++) + { + Assert.That(ServiceResult.IsGood(errors[i]), Is.True); + } + return results[0]; + } + + private async Task FindChildAsync( + NodeId parentId, + NodeId referenceTypeId, + string browseName) + { + ArrayOf references = await BrowseAsync( + parentId, + referenceTypeId).ConfigureAwait(false); + ReferenceDescription? match = null; + for (int i = 0; i < references.Count; i++) + { + if (references[i].BrowseName.Name == browseName) + { + match = references[i]; + break; + } + } + Assert.That(match, Is.Not.Null, $"{browseName} was not found below {parentId}."); + return ExpandedNodeId.ToNodeId(match!.NodeId, m_session!.NamespaceUris); + } + + private async Task AssertZeroFrameComponentsAsync(NodeId frameId) + { + NodeId positionId = await FindChildAsync( + frameId, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "Position").ConfigureAwait(false); + NodeId orientationId = await FindChildAsync( + frameId, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "Orientation").ConfigureAwait(false); + + foreach (string browseName in new[] { "X", "Y", "Z" }) + { + await AssertZeroComponentAsync( + positionId, + browseName).ConfigureAwait(false); + } + foreach (string browseName in new[] { "A", "B", "C" }) + { + await AssertZeroComponentAsync( + orientationId, + browseName).ConfigureAwait(false); + } + } + + private async Task AssertZeroComponentAsync( + NodeId parentId, + string browseName) + { + NodeId componentId = await FindChildAsync( + parentId, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + browseName).ConfigureAwait(false); + DataValue componentValue = await m_session!.ReadValueAsync( + componentId, + CancellationToken.None).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That( + StatusCode.IsGood(componentValue.StatusCode), + Is.True, + $"{browseName} status was {componentValue.StatusCode}."); + Assert.That( + componentValue.WrappedValue.TryGetValue( + out double numericValue), + Is.True, + $"{browseName} did not contain a Double."); + Assert.That(numericValue, Is.Zero, $"{browseName} was not zero."); + }); + } + + [Test] + public async Task OpenUsdCompanionModelIsDeployedAndServedAsync() + { + // The running server advertises the Robotics + OpenUSD namespaces (proving + // the runtime-imported Robotics NodeSet and the source-generated OpenUSD + // model both loaded) and serves the OpenUSD representation type node. + int usdNs = m_session!.NamespaceUris.GetIndex("http://opcfoundation.org/UA/OpenUSD/"); + int roboNs = m_session!.NamespaceUris.GetIndex("http://opcfoundation.org/UA/Robotics/"); + Assert.Multiple(() => + { + Assert.That(usdNs, Is.GreaterThan(0), "OpenUSD namespace not advertised."); + Assert.That(roboNs, Is.GreaterThan(0), "Robotics namespace not advertised."); + }); + + // Resolved from the generated symbolic constant (an ExpandedNodeId carrying + // the namespace URI) rather than a numeric literal, so a model renumbering + // can never silently invalidate this assertion. + NodeId repType = ExpandedNodeId.ToNodeId( + Opc.Ua.OpenUsd.ObjectTypeIds.OpenUsdRepresentationType, m_session!.NamespaceUris); + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + string bn = await connector.ReadBrowseNameAsync(repType, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(bn, Is.EqualTo("OpenUsdRepresentationType")); + } + + [Test] + public async Task AllRepresentationsAreDiscoverableAsync() + { + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + List reps = await AllRepsAsync(connector).ConfigureAwait(false); + + OpenUsdConnector.RepresentationInfo? cell = reps.Find(r => r.PrimPath == CellPrim); + OpenUsdConnector.RepresentationInfo? r1 = reps.Find(r => r.PrimPath == R1Prim); + int axisReps = reps.FindAll(r => + r.PrimPath != null && + r.PrimPath.Contains("/Base/J", StringComparison.Ordinal)).Count; + + Assert.Multiple(() => + { + // 1 system + 2 robots + 12 axes = 15 representations in the registry. + Assert.That(reps, Has.Count.GreaterThanOrEqualTo(15), "Expected >= 15 representations."); + Assert.That(cell, Is.Not.Null, "System (RobotCell) representation not discovered."); + Assert.That(cell!.RootLayerIdentifier, Is.EqualTo("asset-repo/Cell.usd")); + Assert.That(r1, Is.Not.Null, "Robot R1 representation not discovered."); + Assert.That(axisReps, Is.EqualTo(12), "Expected 12 axis representations (6 per robot)."); + // The system carries the RobotsAggregation (Many) component binding. + Assert.That(cell.Components.Exists(c => c.Cardinality == OpenUsdCardinality.Many), Is.True, + "System representation is missing the Many RobotsAggregation."); + // Each robot carries the AxesAggregation (Many) component binding (nested). + Assert.That(r1!.Components.Exists(c => c.Cardinality == OpenUsdCardinality.Many), Is.True, + "Robot representation is missing the Many AxesAggregation (nested composition)."); + }); + } + + [Test] + public async Task PositioningModelsExposeRobotLocationsAsync() + { + int rslNamespaceIndex = m_session!.NamespaceUris.GetIndex( + Rsl.Namespaces.RSL); + int gposNamespaceIndex = m_session.NamespaceUris.GetIndex( + Gpos.Namespaces.GPOS); + Assert.Multiple(() => + { + Assert.That(rslNamespaceIndex, Is.GreaterThan(0)); + Assert.That(gposNamespaceIndex, Is.GreaterThan(0)); + }); + ArrayOf namespaceMetadata = await BrowseAsync( + global::Opc.Ua.ObjectIds.Server_Namespaces, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + (uint)NodeClass.Object).ConfigureAwait(false); + bool hasRslMetadata = false; + bool hasGposMetadata = false; + for (int i = 0; i < namespaceMetadata.Count; i++) + { + QualifiedName browseName = namespaceMetadata[i].BrowseName; + hasRslMetadata |= + browseName.NamespaceIndex == rslNamespaceIndex && + browseName.Name == Rsl.Namespaces.RSL; + hasGposMetadata |= + browseName.NamespaceIndex == gposNamespaceIndex && + browseName.Name == Gpos.Namespaces.GPOS; + } + Assert.Multiple(() => + { + Assert.That(hasRslMetadata, Is.True); + Assert.That(hasGposMetadata, Is.True); + }); + + var relativeSpatialLocationsId = NodeId.Create( + Rsl.Objects.RelativeSpatialLocations, + Rsl.Namespaces.RSL, + m_session!.NamespaceUris); + var globalLocationsId = NodeId.Create( + Gpos.Objects.GlobalLocations, + Gpos.Namespaces.GPOS, + m_session.NamespaceUris); + + ArrayOf lists = await BrowseAsync( + relativeSpatialLocationsId, + + global::Opc.Ua.ReferenceTypeIds.Organizes, + (uint)NodeClass.Object).ConfigureAwait(false); + ArrayOf zones = await BrowseAsync( + globalLocationsId, + + global::Opc.Ua.ReferenceTypeIds.Organizes, + (uint)NodeClass.Object).ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(lists, Has.Count.EqualTo(1)); + Assert.That(zones, Has.Count.EqualTo(1)); + }); + + var listId = ExpandedNodeId.ToNodeId( + lists[0].NodeId, + m_session.NamespaceUris); + var zoneId = ExpandedNodeId.ToNodeId( + zones[0].NodeId, + m_session.NamespaceUris); + NodeId worldFrameId = await FindChildAsync( + listId, + + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "WorldFrame").ConfigureAwait(false); + await AssertZeroFrameComponentsAsync(worldFrameId) + .ConfigureAwait(false); + + ArrayOf spatialObjects = await BrowseAsync( + listId, + + global::Opc.Ua.ReferenceTypeIds.Organizes, + (uint)NodeClass.Object).ConfigureAwait(false); + Assert.That(spatialObjects, Has.Count.EqualTo(2)); + + for (int spatialIndex = 0; + spatialIndex < spatialObjects.Count; + spatialIndex++) + { + ReferenceDescription spatialReference = + spatialObjects[spatialIndex]; + var spatialObjectId = ExpandedNodeId.ToNodeId( + spatialReference.NodeId, + m_session.NamespaceUris); + NodeId positionFrameId = await FindChildAsync( + spatialObjectId, + + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "PositionFrame").ConfigureAwait(false); + NodeId baseId = await FindChildAsync( + positionFrameId, + + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "Base").ConfigureAwait(false); + DataValue baseValue = await m_session.ReadValueAsync( + baseId, + CancellationToken.None).ConfigureAwait(false); + Assert.That(baseValue.WrappedValue.TryGetValue(out NodeId frameBase), Is.True); + Assert.That(frameBase, Is.EqualTo(worldFrameId)); + NodeId frameXId = await FindChildAsync( + await FindChildAsync( + positionFrameId, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "Position").ConfigureAwait(false), + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "X").ConfigureAwait(false); + // The robot is mobile, so its frame moves continuously. Read the + // composite frame and its X component in a single Read so the + // server samples both from the same instant; two separate reads + // compare two different positions and cannot agree exactly. + var frameReads = new ReadValueId[] + { + new ReadValueId + { + NodeId = positionFrameId, + AttributeId = Attributes.Value + }, + new ReadValueId + { + NodeId = frameXId, + AttributeId = Attributes.Value + } + }; + ReadResponse frameRead = await m_session.ReadAsync( + null, + 0, + TimestampsToReturn.Neither, + frameReads, + CancellationToken.None).ConfigureAwait(false); + ThreeDFrame frame = null!; + Assert.That( + frameRead.Results[0].WrappedValue.TryGetValue( + out frame!, + m_session.MessageContext), + Is.True); + Assert.That( + frameRead.Results[1].WrappedValue.TryGetValue(out double frameX), + Is.True); + Assert.That( + frameX, + Is.EqualTo(frame.CartesianCoordinates.X).Within(1e-9)); + + ArrayOf owners = await BrowseAsync( + spatialObjectId, + + global::Opc.Ua.ReferenceTypeIds.HasAddIn, + (uint)NodeClass.Object, + BrowseDirection.Inverse).ConfigureAwait(false); + Assert.That(owners, Has.Count.EqualTo(1)); + var robotId = ExpandedNodeId.ToNodeId( + owners[0].NodeId, + m_session!.NamespaceUris); + if (owners[0].BrowseName.Name == "R1") + { + NodeId attachPointsId = await FindChildAsync( + spatialObjectId, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "AttachPoints").ConfigureAwait(false); + NodeId toolFlangeId = await FindChildAsync( + attachPointsId, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "ToolFlange").ConfigureAwait(false); + await AssertZeroFrameComponentsAsync(toolFlangeId) + .ConfigureAwait(false); + } + NodeId globalLocationId = await FindChildAsync( + robotId, + + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "GlobalLocation").ConfigureAwait(false); + NodeId positionId = await FindChildAsync( + globalLocationId, + + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "Position").ConfigureAwait(false); + NodeId sourceId = await FindChildAsync( + positionId, + + global::Opc.Ua.ReferenceTypeIds.HasProperty, + "SourceId").ConfigureAwait(false); + DataValue sourceValue = await m_session.ReadValueAsync( + sourceId, + CancellationToken.None).ConfigureAwait(false); + Assert.That(sourceValue.WrappedValue.TryGetValue(out NodeId source), Is.True); + Assert.That(source, Is.EqualTo(zoneId)); + NodeId orientationId = await FindChildAsync( + globalLocationId, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "Orientation").ConfigureAwait(false); + NodeId orientationCId = await FindChildAsync( + orientationId, + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "C").ConfigureAwait(false); + // Same reason as the position frame above: the orientation turns + // as the robot drives, so the composite and its C component have + // to be sampled by one Read to be comparable. + var orientationReads = new ReadValueId[] + { + new ReadValueId + { + NodeId = orientationId, + AttributeId = Attributes.Value + }, + new ReadValueId + { + NodeId = orientationCId, + AttributeId = Attributes.Value + } + }; + ReadResponse orientationRead = await m_session.ReadAsync( + null, + 0, + TimestampsToReturn.Neither, + orientationReads, + CancellationToken.None).ConfigureAwait(false); + ThreeDOrientation orientation = null!; + Assert.That( + orientationRead.Results[0].WrappedValue.TryGetValue( + out orientation!, + m_session.MessageContext), + Is.True); + Assert.That( + orientationRead.Results[1].WrappedValue.TryGetValue( + out double orientationC), + Is.True); + Assert.That( + orientationC, + Is.EqualTo(orientation.C).Within(1e-9)); + } + } + + [Test] + public async Task RobotPositionsDriveLiveOpenUsdSceneAsync() + { + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + List representations = + await AllRepsAsync(connector).ConfigureAwait(false); + OpenUsdConnector.RepresentationInfo? r1 = representations.Find( + representation => representation.PrimPath == R1Prim); + OpenUsdConnector.RepresentationInfo? r2 = representations.Find( + representation => representation.PrimPath == R2Prim); + Assert.That(r1, Is.Not.Null); + Assert.That(r2, Is.Not.Null); + var r1Properties = new HashSet(StringComparer.Ordinal); + var r2Properties = new HashSet(StringComparer.Ordinal); + foreach (OpenUsdConnector.BindingInfo binding in r1!.Bindings) + { + if (binding.PropertyName != null) + { + r1Properties.Add(binding.PropertyName); + } + } + foreach (OpenUsdConnector.BindingInfo binding in r2!.Bindings) + { + if (binding.PropertyName != null) + { + r2Properties.Add(binding.PropertyName); + } + } + string[] expectedProperties = + [ + "xformOp:translate", + "xformOp:rotateXYZ", + "inputs:latitude", + "inputs:longitude", + "inputs:elevation" + ]; + Assert.Multiple(() => + { + Assert.That( + r1Properties, + Is.SupersetOf(expectedProperties), + $"R1 bindings: {string.Join(", ", r1Properties)}"); + Assert.That( + r2Properties, + Is.SupersetOf(expectedProperties), + $"R2 bindings: {string.Join(", ", r2Properties)}"); + }); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + bool authored = await PollAsync( + () => sink.WasWritten(R1Prim, "xformOp:translate") && + sink.WasWritten(R2Prim, "xformOp:translate") && + sink.WasWritten(R1Prim, "xformOp:rotateXYZ") && + sink.WasWritten(R2Prim, "xformOp:rotateXYZ") && + sink.WasWritten(R1Prim, "inputs:latitude") && + sink.WasWritten(R1Prim, "inputs:longitude") && + sink.WasWritten(R1Prim, "inputs:elevation") && + sink.WasWritten(R2Prim, "inputs:latitude") && + sink.WasWritten(R2Prim, "inputs:longitude") && + sink.WasWritten(R2Prim, "inputs:elevation"), + TimeSpan.FromSeconds(15)).ConfigureAwait(false); + Assert.That(authored, Is.True); + + Assert.That( + sink.TryGetWritten(R1Prim, "xformOp:translate", out Variant firstR1), + Is.True); + Assert.That( + sink.TryGetWritten(R2Prim, "xformOp:translate", out Variant firstR2), + Is.True); + Assert.That(firstR1.TryGetValue(out ArrayOf firstR1Vector), Is.True); + Assert.That(firstR2.TryGetValue(out ArrayOf firstR2Vector), Is.True); + Assert.That( + Math.Abs(firstR1Vector[0] - firstR2Vector[0]), + Is.GreaterThan(1.0)); + + await Task.Delay(1200).ConfigureAwait(false); + Assert.That( + sink.TryGetWritten(R1Prim, "xformOp:translate", out Variant secondR1), + Is.True); + Assert.That( + sink.TryGetWritten(R2Prim, "xformOp:translate", out Variant secondR2), + Is.True); + Assert.That(secondR1.TryGetValue(out ArrayOf secondR1Vector), Is.True); + Assert.That(secondR2.TryGetValue(out ArrayOf secondR2Vector), Is.True); + Assert.Multiple(() => + { + Assert.That( + Math.Abs(secondR1Vector[0] - firstR1Vector[0]) + + Math.Abs(secondR1Vector[1] - firstR1Vector[1]), + Is.GreaterThan(0.001)); + Assert.That( + Math.Abs(secondR2Vector[0] - firstR2Vector[0]) + + Math.Abs(secondR2Vector[1] - firstR2Vector[1]), + Is.GreaterThan(0.001)); + Assert.That( + Math.Abs(secondR1Vector[0] - secondR2Vector[0]), + Is.GreaterThan(1.0)); + }); + } + finally + { + await connector.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task PositioningClientsConsumeRobotLocationsAsync() + { + var rslClient = new RelativeSpatialLocationClient( + m_session!, + m_telemetry); + var lists = new List(); + await foreach (PositioningObjectEntry entry in + rslClient.EnumerateSpatialObjectListsAsync( + CancellationToken.None).ConfigureAwait(false)) + { + lists.Add(entry); + } + Assert.That(lists, Has.Count.EqualTo(1)); + + var spatialObjects = new List(); + await foreach (PositioningObjectEntry entry in + rslClient.EnumerateSpatialObjectsAsync( + lists[0].NodeId, + CancellationToken.None).ConfigureAwait(false)) + { + spatialObjects.Add(entry); + } + Assert.That(spatialObjects, Has.Count.EqualTo(2)); + RelativeSpatialFrameValue frame = await rslClient + .ReadPositionFrameAsync( + spatialObjects[0].NodeId, + CancellationToken.None).ConfigureAwait(false); + ResolvedRelativeSpatialFrame resolvedFrame = await rslClient + .ResolveFrameToWorldAsync( + frame.NodeId, + AngleUnit.Degrees, + CancellationToken.None).ConfigureAwait(false); + // The robot drives continuously, so the frame read above and the + // server-side resolve are two samples of a moving value and can never + // match exactly. Bracket the resolve with a second read: the world + // transform must equal the frame's own translation at some instant + // between them, which is exactly the invariant under test - a chain + // that wrongly contributed an offset would fall outside the bracket. + RelativeSpatialFrameValue frameAfter = await rslClient + .ReadPositionFrameAsync( + spatialObjects[0].NodeId, + CancellationToken.None).ConfigureAwait(false); + double frameXLower = Math.Min( + frame.Frame.CartesianCoordinates.X, + frameAfter.Frame.CartesianCoordinates.X); + double frameXUpper = Math.Max( + frame.Frame.CartesianCoordinates.X, + frameAfter.Frame.CartesianCoordinates.X); + Assert.Multiple(() => + { + Assert.That(frame.BaseNodeId.IsNull, Is.False); + Assert.That(frame.Frame.CartesianCoordinates, Is.Not.Null); + Assert.That(frame.Frame.Orientation, Is.Not.Null); + Assert.That(StatusCode.IsGood(frame.StatusCode), Is.True); + Assert.That(resolvedFrame.FrameChain.Count, Is.EqualTo(2)); + }); + + var gposClient = new GlobalPositioningClient( + m_session!, + m_telemetry); + var zones = new List(); + await foreach (PositioningObjectEntry entry in + gposClient.EnumerateZonesAsync( + CancellationToken.None).ConfigureAwait(false)) + { + zones.Add(entry); + } + Assert.That(zones, Has.Count.EqualTo(1)); + + ArrayOf owners = await BrowseAsync( + spatialObjects[0].NodeId, + + global::Opc.Ua.ReferenceTypeIds.HasAddIn, + (uint)NodeClass.Object, + BrowseDirection.Inverse).ConfigureAwait(false); + var robotId = ExpandedNodeId.ToNodeId( + owners[0].NodeId, + m_session!.NamespaceUris); + NodeId globalLocationId = await FindChildAsync( + robotId, + + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "GlobalLocation").ConfigureAwait(false); + NodeId globalPositionId = await FindChildAsync( + globalLocationId, + + global::Opc.Ua.ReferenceTypeIds.HasComponent, + "Position").ConfigureAwait(false); + Positioning.Client.GlobalLocationValue location = + await gposClient + .ReadGlobalLocationAsync( + globalLocationId, + CancellationToken.None).ConfigureAwait(false); + Positioning.Client.GlobalPositionValue position = + await gposClient + .ReadGlobalPositionAsync( + globalPositionId, + CancellationToken.None).ConfigureAwait(false); + ThreeDCartesianCoordinates localPosition = await gposClient + .GlobalToLocalAsync( + zones[0].NodeId, + location.Location.Position, + AngleUnit.Degrees, + cancellationToken: CancellationToken.None) + .ConfigureAwait(false); + S3DGeographicCoordinateDataType roundTrip = await gposClient + .LocalToGlobalAsync( + zones[0].NodeId, + localPosition, + AngleUnit.Degrees, + cancellationToken: CancellationToken.None) + .ConfigureAwait(false); + RelativeSpatialFrameValue observedFrame = await ReadSingleAsync( + rslClient.ObserveFrameAsync( + frame.NodeId, + new SingleValueStreamingSubscription( + new DataValue( + Variant.From(new ExtensionObject(frame.Frame)), + StatusCodes.Good)))) + .ConfigureAwait(false); + Positioning.Client.GlobalLocationValue observedLocation = + await ReadSingleAsync( + gposClient.ObserveGlobalLocationAsync( + globalLocationId, + new SingleValueStreamingSubscription( + new DataValue( + Variant.From(new ExtensionObject(location.Location)), + StatusCodes.Good)))) + .ConfigureAwait(false); + PositioningNodeVersionChange nodeVersion = await ReadSingleAsync( + rslClient.ObserveNodeVersionAsync( + lists[0].NodeId, + new SingleValueStreamingSubscription( + new DataValue(Variant.From("3"), StatusCodes.Good)))) + .ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(location.Location.Position.Longitude, Is.Not.Zero); + Assert.That(location.Location.Position.Latitude, Is.Not.Zero); + Assert.That(location.SourceNodeId, Is.EqualTo(zones[0].NodeId)); + Assert.That(location.CoordinateReferenceSystem, Is.EqualTo(4326)); + Assert.That(position.SourceNodeId, Is.EqualTo(zones[0].NodeId)); + Assert.That(position.CoordinateReferenceSystem, Is.EqualTo(4326)); + Assert.That( + resolvedFrame.TransformToWorld.Translation.X, + Is.InRange(frameXLower - 1e-9, frameXUpper + 1e-9)); + Assert.That( + roundTrip.Longitude, + Is.EqualTo(location.Location.Position.Longitude).Within(1e-8)); + Assert.That(observedFrame.NodeId, Is.EqualTo(frame.NodeId)); + Assert.That( + observedLocation.NodeId, + Is.EqualTo(globalLocationId)); + Assert.That(nodeVersion.NodeVersion, Is.EqualTo("3")); + Assert.That(StatusCode.IsGood(location.StatusCode), Is.True); + }); + } + + [Test] + public async Task AxisPositionsArticulateJointsAsync() + { + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + await Task.Delay(3000, CancellationToken.None).ConfigureAwait(false); + await connector.StopAsync().ConfigureAwait(false); + + Assert.Multiple(() => + { + // Each Axis' ActualPosition drives the rotate op of its (nested) link + // prim โ€” the live kinematic articulation, on both robots. + Assert.That(sink.WasWritten("/Cell/Robots/R1/Base/J1", "xformOp:rotateZ"), Is.True, + "R1 axis A1 did not articulate."); + Assert.That(sink.WasWritten("/Cell/Robots/R1/Base/J1/J2/J3", "xformOp:rotateY"), Is.True, + "R1 axis A3 did not articulate."); + Assert.That(sink.WasWritten("/Cell/Robots/R2/Base/J1", "xformOp:rotateZ"), Is.True, + "R2 axis A1 did not articulate."); + Assert.That(sink.TotalWrites, Is.GreaterThan(0)); + }); + } + + [Test] + public async Task RobotsAggregateAsReferencePrimsAsync() + { + // 1..n composition (ยง5.12): the cell aggregates its robots as reference prims + // (Reference, not Instance, so each articulates independently). + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + bool composed = await PollAsync( + () => sink.WasPrimComposed(R1Prim) && sink.WasPrimComposed(R2Prim), + TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.Multiple(() => + { + Assert.That(composed, Is.True, "Both robot prims should be composed."); + Assert.That(sink.IsPrimActive(R1Prim), Is.True, "R1 prim not active."); + Assert.That(sink.IsPrimActive(R2Prim), Is.True, "R2 prim not active."); + }); + } + finally + { + await connector.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task AxesComposeAsNestedChildMarkersAsync() + { + // Nested composition (ยง5.12): each robot is composed of its 6 axes (Many, + // Child) โ€” the connector composes an axis component prim per axis under the + // robot scope. + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + bool composed = await PollAsync( + () => sink.WasPrimComposed("/Cell/Robots/R1/A1") && sink.WasPrimComposed("/Cell/Robots/R1/A6"), + TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Assert.That(composed, Is.True, + "Axis component prims (nested Many composition) were not composed."); + } + finally + { + await connector.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task EmergencyStopDrivesSafetyVisualsAsync() + { + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + // The emergency-stop pulses periodically; wait long enough to observe at + // least one visibility authoring on the beacon and a robot warning halo. + bool authored = await PollAsync( + () => sink.WasWritten("/Cell/SafetyBeacon", "visibility") && + sink.WasWritten("/Cell/Robots/R1/Warning", "visibility"), + TimeSpan.FromSeconds(35)).ConfigureAwait(false); + await connector.StopAsync().ConfigureAwait(false); + + Assert.That(authored, Is.True, + "Emergency-stop alarm bindings did not author the safety beacon + robot warning visibility."); + } + + [Test] + public async Task DynamicToolIsComposedAsync() + { + // Dynamic composition (ยง5.13): the server attaches a gripper tool on R1's + // flange at runtime (emitting model-change events); the connector reconciles + // the tool reference prim. + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + bool appeared = await PollAsync( + () => sink.WasPrimComposed(ToolPrim) && sink.IsPrimActive(ToolPrim), + TimeSpan.FromSeconds(30)).ConfigureAwait(false); + Assert.That(appeared, Is.True, "Dynamically attached gripper tool prim was not composed."); + } + finally + { + await connector.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task DynamicToolIsDeactivatedWhenDetachedAsync() + { + // ยง5.13: "a removed component -> author active = false on its instance prim." + // The gripper is a One + Reference + dynamic component, so the deactivation + // path must also run for Cardinality = One (the robotics worked example). + // The sample cycles mount -> detach on R1's flange; the detach emits a model + // change, the connector recomposes, resolves zero matches and must author + // active = false on the tool prim instead of leaving it active forever. + var sink = new MockUsdSink(); + var connector = new OpenUsdConnector(m_session!, sink); + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + try + { + bool deactivated = await PollAsync( + () => sink.WasPrimComposed(ToolPrim) && !sink.IsPrimActive(ToolPrim), + TimeSpan.FromSeconds(60)).ConfigureAwait(false); + Assert.That(deactivated, Is.True, + "Detached One-cardinality component was never deactivated (active = false)."); + } + finally + { + await connector.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public void SpeedOverrideCommandIsFailClosedByDefault() + { + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + // Opt-in: with commands disabled (the default), actuation is refused. + Assert.ThrowsAsync(async () => + await connector.IssueCommandAsync(50.0, CancellationToken.None).ConfigureAwait(false)); + } + + [Test] + public async Task SpeedOverrideCommandWritesServerVariableWhenEnabledAsync() + { + var connector = new OpenUsdConnector( + m_privilegedSession!, new MockUsdSink(), enableCommands: true); + List reps = await AllRepsAsync(connector).ConfigureAwait(false); + NodeId target = NodeId.Null; + foreach (OpenUsdConnector.RepresentationInfo rep in reps) + { + foreach (OpenUsdConnector.BindingInfo b in rep.Bindings) + { + if (b.Intent == OpenUsdIntentProfile.UsdToUaCommand) + { + target = b.CommandTargetNodeId; + } + } + } + Assert.That(target.IsNull, Is.False, "Command target NodeId missing."); + + const double setpoint = 63.5; + bool ok = await connector.IssueCommandAsync(setpoint, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(ok, Is.True, "Command write did not succeed."); + + var toRead = new ReadValueId[] + { + new ReadValueId { NodeId = target, AttributeId = Attributes.Value } + }; + ReadResponse rr = await m_privilegedSession!.ReadAsync( + null, 0, TimestampsToReturn.Neither, toRead, CancellationToken.None) + .ConfigureAwait(false); + Assert.That( + rr.Results[0].WrappedValue.TryGetValue(out double actual), Is.True, + "Server SpeedOverride did not read back as a Double."); + Assert.That(actual, Is.EqualTo(setpoint).Within(1e-9), + "Server SpeedOverride was not updated by the command binding."); + } + + [Test] + public async Task StageRootLayerDigestVerifiesAsync() + { + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + List reps = await AllRepsAsync(connector).ConfigureAwait(false); + OpenUsdConnector.RepresentationInfo? cell = reps.Find(r => r.PrimPath == CellPrim); + Assert.That(cell, Is.Not.Null); + + // ยง5.2: the digest covers the *resolved root-layer content*, not the + // identifier string, and a connector shall refuse a layer whose digest + // does not match. + Assert.That(cell!.DigestAlgorithm, Is.EqualTo(OpenUsdDigestAlgorithm.Sha256)); + Assert.That(cell!.RootLayerDigest.IsNull, Is.False); + Assert.That(cell!.RootLayerDigest.Length, Is.EqualTo(32)); + bool verified = await connector + .VerifyStageDigestAsync(cell!, CancellationToken.None).ConfigureAwait(false); + Assert.That(verified, Is.True, "RootLayerDigest failed verification."); + + byte[] corrupted = System.Text.Encoding.UTF8.GetBytes("#usda 1.0\n(tampered)\n"); + Assert.That(OpenUsdConnector.VerifyStageDigest(cell!, corrupted), Is.False, + "Connector accepted a root layer whose content does not match the digest."); + } + + [Test] + public async Task ServedAssetsAreFetchedVerifiedAndCachedAsync() + { + // ยง5.15 asset content delivery: the server serves its USD layer closure + // (Cell.usda RootLayer + robot.usda + tool.usda) via Part 5 FileType; the + // connector streams, verifies each digest, and caches them locally. + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + string cacheDir = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "RobotAssetCache", System.IO.Path.GetRandomFileName()); + try + { + List assets = + await connector.FetchServedAssetsAsync(cacheDir, CancellationToken.None).ConfigureAwait(false); + + OpenUsdConnector.FetchedAsset? root = assets.Find(a => a.Kind == OpenUsdAssetKind.RootLayer); + Assert.Multiple(() => + { + Assert.That(assets, Has.Count.EqualTo(3), "Expected 3 served layers."); + Assert.That(assets.Exists(a => a.Identifier == "Cell.usda" && + a.Kind == OpenUsdAssetKind.RootLayer), Is.True, "Cell.usda RootLayer not served."); + Assert.That(assets.Exists(a => a.Identifier == "robot.usda"), Is.True, "robot.usda not served."); + Assert.That(assets.Exists(a => a.Identifier == "tool.usda"), Is.True, "tool.usda not served."); + Assert.That(assets.TrueForAll(a => a.DigestVerified), Is.True, + "A served layer failed digest verification."); + Assert.That(assets.TrueForAll(a => + System.IO.File.Exists(a.LocalPath) && new System.IO.FileInfo(a.LocalPath).Length > 0), + Is.True, "A served layer was not cached to disk."); + Assert.That(root, Is.Not.Null, "No RootLayer served."); + Assert.That(System.IO.File.ReadAllText(root!.LocalPath).TrimStart(), + Does.StartWith("#usda"), "Cached root layer is not valid USD text."); + }); + } + finally + { + if (System.IO.Directory.Exists(cacheDir)) + { + System.IO.Directory.Delete(cacheDir, recursive: true); + } + } + } + } +} diff --git a/tests/Opc.Ua.Features.Tests/FileSystemClientIntegrationTests.cs b/tests/Opc.Ua.Features.Tests/FileSystemClientIntegrationTests.cs index 00a022a41a..ddc5960342 100644 --- a/tests/Opc.Ua.Features.Tests/FileSystemClientIntegrationTests.cs +++ b/tests/Opc.Ua.Features.Tests/FileSystemClientIntegrationTests.cs @@ -240,6 +240,28 @@ public async Task CreateDirectoryThenSubdirectoryAsync() Assert.That(found, Is.InstanceOf()); } + [Test] + public async Task ExplicitlyQualifiedPathResolvesToTheSameNodeAsync() + { + FileSystemClient client = OpenClient(); + UaDirectoryInfo created = await client.Root + .CreateSubdirectoryAsync("qualified") + .ConfigureAwait(false); + + ushort namespaceIndex = created.NodeId.NamespaceIndex; + Assert.That(namespaceIndex, Is.Not.Zero, + "The provider is expected to own a namespace of its own."); + + UaFileSystemInfo unqualified = await client + .GetInfoAsync("qualified").ConfigureAwait(false); + UaFileSystemInfo qualified = await client + .GetInfoAsync($"{namespaceIndex}:qualified").ConfigureAwait(false); + + Assert.That(unqualified, Is.InstanceOf()); + Assert.That(qualified, Is.InstanceOf()); + Assert.That(qualified.NodeId, Is.EqualTo(created.NodeId)); + } + [Test] public async Task DeleteFileRemovesFromEnumerationAsync() { diff --git a/tests/Opc.Ua.ISA95.Tests/Common/Isa95GeoSpatialLocationTests.cs b/tests/Opc.Ua.ISA95.Tests/Common/Isa95GeoSpatialLocationTests.cs index 83fed4adf1..e34c69a010 100644 --- a/tests/Opc.Ua.ISA95.Tests/Common/Isa95GeoSpatialLocationTests.cs +++ b/tests/Opc.Ua.ISA95.Tests/Common/Isa95GeoSpatialLocationTests.cs @@ -48,91 +48,87 @@ namespace Opc.Ua.ISA95.Tests.Common [TestFixture] public class Isa95GeoSpatialLocationTests { - [Test] - public async Task StaticProviderReadReturnsCurrentValue() - { - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); - - Isa95GeoSpatialLocation location = await provider.GetCurrentAsync().ConfigureAwait(false); - - Assert.That(location.Value, Is.EqualTo("Berlin")); - Assert.That(location.StatusCode, Is.EqualTo(StatusCodes.Good)); - } + private const string SourceId = "plant"; + private static readonly string[] s_berlin = ["Berlin"]; + private static readonly string[] s_munich = ["Munich"]; + private static readonly string[] s_zurichPlant = ["Building 4, Zurich Plant"]; + private static readonly string[] s_wktWithHeight = + ["SRID=4326;POINT Z (8.5417 47.3769 408)"]; + private static readonly string[] s_wktAndLabel = + ["POINT (8.5417 47.3769)", "Building 4, Zurich Plant"]; [Test] - public async Task StaticProviderUpdateNotifiesSubscribersInOrder() + public async Task BinderReadServesProviderLabels() { - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); - using var cts = new CancellationTokenSource(); - - await using IAsyncEnumerator enumerator = - provider.SubscribeAsync(cts.Token).GetAsyncEnumerator(cts.Token); + var fixture = new Isa95CommonTestContext(); + GeoSpatialLocationState state = CreateLocationVariable(fixture); + using InMemoryGeoLocationProvider provider = CreateProvider("Berlin"); + Isa95ModelBuilder builder = fixture.CreateBuilder(); - ValueTask first = enumerator.MoveNextAsync(); - provider.Update(Isa95GeoSpatialLocation.Good("Munich")); - Assert.That(await first.ConfigureAwait(false), Is.True); - Assert.That(enumerator.Current.Value, Is.EqualTo("Munich")); + using IDisposable binding = builder.BindGeoSpatialLocation( + state, + provider, + SourceId); - ValueTask second = enumerator.MoveNextAsync(); - provider.Update("Hamburg", StatusCodes.Good, DateTime.UtcNow); - Assert.That(await second.ConfigureAwait(false), Is.True); - Assert.That(enumerator.Current.Value, Is.EqualTo("Hamburg")); + AttributeReadResult result = await state.OnReadValueAsync!( + fixture.Context, + state, + NumericRange.Null, + QualifiedName.Null, + CancellationToken.None).ConfigureAwait(false); - await cts.CancelAsync().ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(result.Result), Is.True); + Assert.That(ReadLiterals(result.Value), Is.EqualTo(s_berlin)); } [Test] - public async Task StaticProviderSubscriptionEndsOnCancellation() + public async Task BinderProjectsAPositionAsWellKnownText() { - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); - using var cts = new CancellationTokenSource(); - - var consume = Task.Run(async () => - { - await foreach (Isa95GeoSpatialLocation _ in provider.SubscribeAsync(cts.Token)) - { - // Drain until cancelled. - } - }); - - await cts.CancelAsync().ConfigureAwait(false); - await consume.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + var fixture = new Isa95CommonTestContext(); + GeoSpatialLocationState state = CreateLocationVariable(fixture); + using var provider = new InMemoryGeoLocationProvider(); + provider.Update( + SourceId, + new GeoPosition(47.3769, 8.5417, 408.0, EpsgCode: 4326)); + Isa95ModelBuilder builder = fixture.CreateBuilder(); - Assert.That(consume.Status, Is.EqualTo(TaskStatus.RanToCompletion)); - } + using IDisposable binding = builder.BindGeoSpatialLocation( + state, + provider, + SourceId); - [Test] - public void StaticProviderFaultPropagatesToReader() - { - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); - provider.Fault(new InvalidOperationException("boom")); + AttributeReadResult result = await state.OnReadValueAsync!( + fixture.Context, + state, + NumericRange.Null, + QualifiedName.Null, + CancellationToken.None).ConfigureAwait(false); Assert.That( - async () => await provider.GetCurrentAsync().ConfigureAwait(false), - Throws.InvalidOperationException); - } - - [Test] - public async Task StaticProviderUpdateClearsFault() - { - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); - provider.Fault(new InvalidOperationException("boom")); - - provider.Update(Isa95GeoSpatialLocation.Good("Cologne")); - Isa95GeoSpatialLocation location = await provider.GetCurrentAsync().ConfigureAwait(false); - - Assert.That(location.Value, Is.EqualTo("Cologne")); + ReadLiterals(result.Value), + Is.EqualTo(s_wktWithHeight)); } [Test] - public async Task BinderReadServesProviderValue() + public async Task BinderPublishesPositionAndLabelsTogether() { var fixture = new Isa95CommonTestContext(); GeoSpatialLocationState state = CreateLocationVariable(fixture); - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); + using var provider = new InMemoryGeoLocationProvider(); + provider.Update( + SourceId, + new GeoLocationSample( + new GeoPosition(47.3769, 8.5417), + null, + s_zurichPlant.ToArrayOf(), + StatusCodes.Good, + DateTimeUtc.Now)); Isa95ModelBuilder builder = fixture.CreateBuilder(); - using IDisposable binding = builder.BindGeoSpatialLocation(state, provider); + using IDisposable binding = builder.BindGeoSpatialLocation( + state, + provider, + SourceId); AttributeReadResult result = await state.OnReadValueAsync!( fixture.Context, @@ -141,9 +137,9 @@ public async Task BinderReadServesProviderValue() QualifiedName.Null, CancellationToken.None).ConfigureAwait(false); - Assert.That(ServiceResult.IsGood(result.Result), Is.True); - Assert.That(result.Value.TryGetValue(out string text), Is.True); - Assert.That(text, Is.EqualTo("Berlin")); + Assert.That( + ReadLiterals(result.Value), + Is.EqualTo(s_wktAndLabel)); } [Test] @@ -151,11 +147,14 @@ public void BinderReadPropagatesProviderFault() { var fixture = new Isa95CommonTestContext(); GeoSpatialLocationState state = CreateLocationVariable(fixture); - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); - provider.Fault(new InvalidOperationException("boom")); + using InMemoryGeoLocationProvider provider = CreateProvider("Berlin"); + provider.Fault(SourceId, new InvalidOperationException("boom")); Isa95ModelBuilder builder = fixture.CreateBuilder(); - using IDisposable binding = builder.BindGeoSpatialLocation(state, provider); + using IDisposable binding = builder.BindGeoSpatialLocation( + state, + provider, + SourceId); Assert.That( async () => await state.OnReadValueAsync!( @@ -174,11 +173,14 @@ public async Task BinderReadFaultSurfacesAsBadStatusThroughStack() GeoSpatialLocationState state = CreateLocationVariable(fixture); state.AccessLevel = AccessLevels.CurrentRead; state.UserAccessLevel = AccessLevels.CurrentRead; - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); - provider.Fault(new InvalidOperationException("boom")); + using InMemoryGeoLocationProvider provider = CreateProvider("Berlin"); + provider.Fault(SourceId, new InvalidOperationException("boom")); Isa95ModelBuilder builder = fixture.CreateBuilder(); - using IDisposable binding = builder.BindGeoSpatialLocation(state, provider); + using IDisposable binding = builder.BindGeoSpatialLocation( + state, + provider, + SourceId); (ServiceResult result, DataValue value) = await state.ReadAttributeAsync( fixture.Context, @@ -197,9 +199,12 @@ public async Task BinderReadHonoursCancellation() { var fixture = new Isa95CommonTestContext(); GeoSpatialLocationState state = CreateLocationVariable(fixture); - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); + using InMemoryGeoLocationProvider provider = CreateProvider("Berlin"); Isa95ModelBuilder builder = fixture.CreateBuilder(); - using IDisposable binding = builder.BindGeoSpatialLocation(state, provider); + using IDisposable binding = builder.BindGeoSpatialLocation( + state, + provider, + SourceId); using var cts = new CancellationTokenSource(); await cts.CancelAsync().ConfigureAwait(false); @@ -218,15 +223,43 @@ public async Task BinderAppliesUpdatesToVariable() { var fixture = new Isa95CommonTestContext(); GeoSpatialLocationState state = CreateLocationVariable(fixture); - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); + using InMemoryGeoLocationProvider provider = CreateProvider("Berlin"); Isa95ModelBuilder builder = fixture.CreateBuilder(); - using IDisposable binding = builder.BindGeoSpatialLocation(state, provider); + using IDisposable binding = builder.BindGeoSpatialLocation( + state, + provider, + SourceId); - bool applied = await WaitForValueAsync(state, "Munich", provider).ConfigureAwait(false); + bool applied = await WaitForValueAsync(state, "Munich", provider) + .ConfigureAwait(false); Assert.That(applied, Is.True); - Assert.That(state.Value.TryGetValue(out string text), Is.True); - Assert.That(text, Is.EqualTo("Munich")); + Assert.That(ReadLiterals(state.Value), Is.EqualTo(s_munich)); + } + + [Test] + public async Task BinderDoesNotSubscribeWhenTheProviderCannotPush() + { + var fixture = new Isa95CommonTestContext(); + GeoSpatialLocationState state = CreateLocationVariable(fixture); + var provider = new PollOnlyProvider(); + Isa95ModelBuilder builder = fixture.CreateBuilder(); + + using IDisposable binding = builder.BindGeoSpatialLocation( + state, + provider, + SourceId); + + await Task.Delay(50).ConfigureAwait(false); + + Assert.That(provider.WatchCallCount, Is.Zero); + AttributeReadResult result = await state.OnReadValueAsync!( + fixture.Context, + state, + NumericRange.Null, + QualifiedName.Null, + CancellationToken.None).ConfigureAwait(false); + Assert.That(ReadLiterals(result.Value), Is.EqualTo(s_berlin)); } [Test] @@ -254,7 +287,7 @@ public async Task BinderLogsProviderUpdateStreamFailure() var provider = new FaultingUpdateProvider(); using IDisposable binding = - Isa95GeoSpatialLocationBinder.Bind(context, state, provider); + Isa95GeoSpatialLocationBinder.Bind(context, state, provider, SourceId); bool logged = false; for (int attempt = 0; attempt < 100 && !logged; attempt++) @@ -292,13 +325,14 @@ public async Task CreateGeoSpatialLocationWithProviderBindsReads() { var fixture = new Isa95CommonTestContext(); Isa95ModelBuilder builder = fixture.CreateBuilder(); - using var provider = new Isa95GeoSpatialLocationProvider("Berlin"); + using InMemoryGeoLocationProvider provider = CreateProvider("Berlin"); using Isa95GeoSpatialLocationBinding binding = await builder.CreateGeoSpatialLocationAsync( fixture.Root, "Location", - provider).ConfigureAwait(false); + provider, + SourceId).ConfigureAwait(false); Assert.That(binding.State.OnReadValueAsync, Is.Not.Null); AttributeReadResult result = await binding.State.OnReadValueAsync!( @@ -307,25 +341,57 @@ await builder.CreateGeoSpatialLocationAsync( NumericRange.Null, QualifiedName.Null, CancellationToken.None).ConfigureAwait(false); - Assert.That(result.Value.TryGetValue(out string text), Is.True); - Assert.That(text, Is.EqualTo("Berlin")); + Assert.That(ReadLiterals(result.Value), Is.EqualTo(s_berlin)); + } + + [Test] + public void CreateGeoSpatialLocationWithProviderRequiresASourceId() + { + var fixture = new Isa95CommonTestContext(); + Isa95ModelBuilder builder = fixture.CreateBuilder(); + using InMemoryGeoLocationProvider provider = CreateProvider("Berlin"); + + Assert.That( + async () => await builder.CreateGeoSpatialLocationAsync( + fixture.Root, + "Location", + provider).ConfigureAwait(false), + Throws.ArgumentException); + } + + private static InMemoryGeoLocationProvider CreateProvider(params string[] labels) + { + var provider = new InMemoryGeoLocationProvider(); + provider.Update(SourceId, GeoLocationSample.Good(labels.ToArrayOf())); + return provider; + } + + private static string[] ReadLiterals(Variant value) + { + return value.TryGetValue(out ArrayOf literals) + ? [.. literals] + : []; } private static async Task WaitForValueAsync( GeoSpatialLocationState state, string expected, - Isa95GeoSpatialLocationProvider provider) + InMemoryGeoLocationProvider provider) { for (int attempt = 0; attempt < 100; attempt++) { - provider.Update(Isa95GeoSpatialLocation.Good(expected)); - if (state.Value.TryGetValue(out string text) && text == expected) + provider.Update( + SourceId, + GeoLocationSample.Good(new[] { expected }.ToArrayOf())); + string[] literals = ReadLiterals(state.Value); + if (literals.Length == 1 && literals[0] == expected) { return true; } await Task.Delay(20).ConfigureAwait(false); } - return state.Value.TryGetValue(out string final) && final == expected; + string[] final = ReadLiterals(state.Value); + return final.Length == 1 && final[0] == expected; } private static GeoSpatialLocationState CreateLocationVariable( @@ -336,24 +402,53 @@ private static GeoSpatialLocationState CreateLocationVariable( new QualifiedName("Location", fixture.InstanceNamespaceIndex)); } - private sealed class FaultingUpdateProvider : IIsa95GeoSpatialLocationProvider + private sealed class FaultingUpdateProvider : IGeoLocationProvider { - public ValueTask GetCurrentAsync( + public bool SupportsPush => true; + + public ValueTask ReadAsync( + string sourceId, CancellationToken cancellationToken = default) { - return new ValueTask( - Isa95GeoSpatialLocation.Good("Berlin")); + return new ValueTask( + GeoLocationSample.Good(s_berlin.ToArrayOf())); } - public async IAsyncEnumerable SubscribeAsync( + public async IAsyncEnumerable WatchAsync( + string sourceId, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - yield return Isa95GeoSpatialLocation.Good("Munich"); + yield return GeoLocationSample.Good(s_munich.ToArrayOf()); await Task.Yield(); throw new InvalidOperationException("stream boom"); } } + private sealed class PollOnlyProvider : IGeoLocationProvider + { + public bool SupportsPush => false; + + public int WatchCallCount => m_watchCallCount; + + public ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken = default) + { + return new ValueTask( + GeoLocationSample.Good(s_berlin.ToArrayOf())); + } + + public async IAsyncEnumerable WatchAsync( + string sourceId, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref m_watchCallCount); + await Task.Yield(); + yield break; + } + + private int m_watchCallCount; + } private sealed class SimpleNodeIdFactory : INodeIdFactory { public NodeId New(ISystemContext context, NodeState node) diff --git a/tests/Opc.Ua.ISA95.Tests/Integration/Isa95EndToEndTests.cs b/tests/Opc.Ua.ISA95.Tests/Integration/Isa95EndToEndTests.cs index fa33b51a9f..fca4640d4c 100644 --- a/tests/Opc.Ua.ISA95.Tests/Integration/Isa95EndToEndTests.cs +++ b/tests/Opc.Ua.ISA95.Tests/Integration/Isa95EndToEndTests.cs @@ -68,8 +68,10 @@ public async Task ServerFixtureAndTypedClientExerciseIsa95Async() TestContext.CurrentContext.WorkDirectory, nameof(Isa95EndToEndTests), Guid.NewGuid().ToString("N")); - using var locationProvider = new Isa95GeoSpatialLocationProvider( - "POINT (8.5417 47.3769)"); + using var locationProvider = new InMemoryGeoLocationProvider(); + locationProvider.Update( + "plant", + new GeoPosition(47.3769, 8.5417, EpsgCode: 4326)); using var jobProvider = new InMemoryIsa95JobControlProvider(); var statusSource = new TrackingStatusSource(jobProvider); Isa95GeoSpatialLocationBinding? locationBinding = null; @@ -138,7 +140,8 @@ await model.CreateMaterialDefinitionAsync( model.Root, "PlantLocation", locationProvider, - ct).ConfigureAwait(false); + "plant", + cancellationToken: ct).ConfigureAwait(false); locationNodeId = locationBinding.State.NodeId; PhysicalAssetPropertyState locationProperty = await model.AddPropertyAsync( @@ -348,10 +351,15 @@ await session.ManagedBrowseAsync( DataValue location = await session.ReadValueAsync(locationNodeId, ct) .ConfigureAwait(false); Assert.That(location.StatusCode, Is.EqualTo(StatusCodes.Good)); + // OPC 10030 declares GeoSpatialLocationType with ValueRank + // OneOrMoreDimensions, so the value is an array of literals. Assert.That( - location.WrappedValue.TryGetValue(out string? value), + location.WrappedValue.TryGetValue(out ArrayOf literals), Is.True); - Assert.That(value, Is.EqualTo("POINT (8.5417 47.3769)")); + Assert.That(literals.Count, Is.EqualTo(1)); + Assert.That( + literals[0], + Is.EqualTo("SRID=4326;POINT (8.5417 47.3769)")); } private static async Task ExerciseJobControlAsync( diff --git a/tests/Opc.Ua.OpenUsd.Tests/AddressSpaceRoundTripTests.cs b/tests/Opc.Ua.OpenUsd.Tests/AddressSpaceRoundTripTests.cs new file mode 100644 index 0000000000..dc56897539 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/AddressSpaceRoundTripTests.cs @@ -0,0 +1,148 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Address-space round trip (ยง7.2, ยง7.4): parse โ†’ materialize โ†’ export and compare with the + /// oracle. Structure, typing and provenance round-trip; two + /// value/connection export defects are pinned as characterization tests (see final report). + /// + [TestFixture] + public class AddressSpaceRoundTripTests + { + // ---- Headline round trip (ยง7.2, ยง7.4) ------------------------------------------ + + [Test] + [TestCase("Plant.usda")] + [TestCase("Cell.usda")] + public void ParseMaterializeExport_RoundTrips_OnStructureAndProvenance(string asset) + { + UsdStage parsed = TestAssets.Load(asset); + MaterializedScene ms = MaterializationHarness.Materialize(parsed); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Stage); + + // The two known exporter value/connection defects are neutralized so the round trip + // asserts on structure, typing, kinds, relationships, arcs and variants (Annex B.4: + // dual-authored portable georeference AddIns are additive and not re-emitted). + parsed.NormalizeValuesAndConnections(); + exported.NormalizeValuesAndConnections(); + + string? difference = UsdSceneSignature.FirstDifference(parsed, exported); + Assert.That(difference, Is.Null, difference); + } + + [Test] + public void ParseMaterializeExport_PreservesGeoreferencedScene() + { + // Annex B.4: a georeferenced scene still round-trips because the portable AddIns the + // materializer dual-authors are deliberately not re-emitted on export. + var stage = new UsdStage("Geo") { DefaultPrim = "Site", UpAxis = "Z", MetersPerUnit = 1.0 }; + var site = new UsdPrim("Site", "Xform"); + site.ApiSchemas.Add(new UsdApiSchema("CesiumGeoreferencePrim")); + site.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = 47.6062 }); + site.Attributes.Add( + new UsdAttribute("cesium:anchor:longitude", "double") { Value = -122.3321 }); + site.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = 56.0 }); + stage.AddRootPrim(site); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdStage exported = ms.Context.ExportUsdStage(ms.Stage); + + stage.NormalizeValuesAndConnections(); + exported.NormalizeValuesAndConnections(); + + string? difference = UsdSceneSignature.FirstDifference(stage, exported); + Assert.That(difference, Is.Null, difference); + } + + [Test] + public void Export_ScalarValue_RoundTrips() + { + var stage = new UsdStage("S") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add(new UsdAttribute("radius", "double") { Value = 2.5 }); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdStage exported = ms.Context.ExportUsdStage(ms.Stage); + + string? difference = UsdSceneSignature.FirstDifference(stage, exported); + Assert.That(difference, Is.Null, difference); + } + + // ---- Exporter value/connection round trip (ยง7.2, ยง5.4) ------------------------- + + [Test] + public void Export_ArrayValue_RoundTrips() + { + // UsdSceneExporter.ExportAttribute reads Value.AsBoxedObject(BoxingBehavior.Legacy), + // which unwraps the ArrayOf/MatrixOf a materialized Variable carries into the + // System.Array shapes UsdValueCoercion.Decoerce consumes, so a fixed-size / array + // value survives export (ยง7.2). Formerly pinned as a known-bug characterization. + var stage = new UsdStage("A") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add( + new UsdAttribute("xformOp:translate", "double3") + { + Value = new object[] { 1.0, 2.0, 3.0 } + }); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdStage exported = ms.Context.ExportUsdStage(ms.Stage); + + string? difference = UsdSceneSignature.FirstDifference(stage, exported); + Assert.That(difference, Is.Null, difference); + } + + [Test] + public void Export_PreservesAttributeConnections() + { + // UsdSceneExporter reconstructs Connections from the forward UsdConnection references + // the materializer authored, mapping each target NodeId back to + // ., so attribute connections survive the round trip + // (ยง5.4, ยง7.2). Formerly pinned as a known-bug characterization. + UsdStage parsed = TestAssets.Load("Plant.usda"); + Assert.That(parsed.TotalConnections(), Is.EqualTo(1)); + + MaterializedScene ms = MaterializationHarness.Materialize(parsed); + UsdStage exported = ms.Context.ExportUsdStage(ms.Stage); + + Assert.That(exported.TotalConnections(), Is.EqualTo(1), + "Attribute connections should survive export."); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/Assets/Cell.usda b/tests/Opc.Ua.OpenUsd.Tests/Assets/Cell.usda new file mode 100644 index 0000000000..e9fbb14785 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/Assets/Cell.usda @@ -0,0 +1,83 @@ +#usda 1.0 +( + doc = """Base robot-cell asset for the OPC UA - OpenUSD Bindings Robotics end-to-end. + + Authored by the 3D / USD asset author actor. It carries only the static cell environment. The generic connector composes the aggregated robot references into live.usda; this base layer provides only positioned /Cell/Robots/R1 and /Cell/Robots/R2 mount-point Xforms. + """ + defaultPrim = "Cell" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Cell" +{ + custom double inputs:speedOverride = 100 + + def Cube "Ground" + { + double size = 1 + color3f[] primvars:displayColor = [(0.22, 0.24, 0.25)] + double3 xformOp:translate = (0, 0, -0.025) + double3 xformOp:scale = (3.8, 2.4, 0.025) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Xform "Fence" + { + def Cube "Back" + { + double size = 1 + color3f[] primvars:displayColor = [(0.95, 0.78, 0.05)] + double3 xformOp:translate = (0, 1.2, 0.45) + double3 xformOp:scale = (3.8, 0.035, 0.45) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + def Cube "Left" + { + double size = 1 + color3f[] primvars:displayColor = [(0.95, 0.78, 0.05)] + double3 xformOp:translate = (-1.9, 0, 0.45) + double3 xformOp:scale = (0.035, 1.2, 0.45) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + def Cube "Right" + { + double size = 1 + color3f[] primvars:displayColor = [(0.95, 0.78, 0.05)] + double3 xformOp:translate = (1.9, 0, 0.45) + double3 xformOp:scale = (0.035, 1.2, 0.45) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + + def Sphere "SafetyBeacon" + { + token visibility = "invisible" + double radius = 0.12 + color3f[] primvars:displayColor = [(1, 0, 0)] + double3 xformOp:translate = (0, 1.05, 1.15) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Robots" + { + def Xform "R1" + { + double3 xformOp:translate = (-1.2, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "R2" + { + double3 xformOp:translate = (1.2, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + + def DistantLight "KeyLight" + { + float intensity = 650 + double3 xformOp:rotateXYZ = (-45, 0, 35) + uniform token[] xformOpOrder = ["xformOp:rotateXYZ"] + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/Assets/Plant.usda b/tests/Opc.Ua.OpenUsd.Tests/Assets/Plant.usda new file mode 100644 index 0000000000..532e807f47 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/Assets/Plant.usda @@ -0,0 +1,119 @@ +#usda 1.0 +( + doc = """Base pump asset for the OPC UA - OpenUSD Bindings pump end-to-end. + + Authored by the '3D / USD asset author' actor (Annex B). It carries only + geometry and materials; it contains NO live values. The live OPC UA values + are composed on top by `live.usda` (see `stage.usda`), which a connector + authors at runtime. The prim paths and attribute names here are the contract + the OpenUSD bindings target: + + /Plant/Pumps/P101/Impeller xformOp:rotateZ (MassFlow -> Rotation) + /Plant/Pumps/P101/Body primvars:displayColor(BearingTemperature -> DisplayColor) + /Plant/Pumps/P101/StatusLight/Mat/Surface.inputs:emissiveColor + (DifferentialPressure -> EmissiveColor) + /Plant/Pumps/P101/StatusLight.visibility (alarm ActiveState -> Visibility) + /Plant/Pumps/P101/Impeller.inputs:speedSetpoint (opt-in command intent -> UA write) + """ + defaultPrim = "Plant" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Plant" +{ + def Scope "Pumps" + { + def Xform "P101" ( + kind = "component" + ) + { + double3 xformOp:translate = (0, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + + # Static pump casing/body. Its displayColor is driven by bearing + # temperature at runtime (blue = cool, red = hot). + def Cylinder "Body" + { + uniform token axis = "Z" + double height = 1.0 + double radius = 0.5 + color3f[] primvars:displayColor = [(0, 0, 1)] + double3 xformOp:translate = (0, 0, 0.5) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + # Bearing sub-component (1:1 composition: the pump is composed of a + # Bearing component Object mapped to this child prim, ยง5.12). + def Sphere "Bearing" + { + double radius = 0.12 + color3f[] primvars:displayColor = [(0.3, 0.3, 0.32)] + double3 xformOp:translate = (0, 0, 0.15) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + # Impeller that spins about the pump's vertical (Z) axis. Its + # xformOp:rotateZ angle is driven by mass flow at runtime. + def Xform "Impeller" + { + double xformOp:rotateZ = 0 + double3 xformOp:translate = (0, 0, 1.05) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateZ"] + + # Opt-in command intent: a controller writes this setpoint on the + # USD side; the connector maps it to a UA write (UsdToUaCommand). + custom double inputs:speedSetpoint = 0 + + def Cube "BladeA" + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.8, 0.8, 0.82)] + double3 xformOp:scale = (0.9, 0.12, 0.04) + uniform token[] xformOpOrder = ["xformOp:scale"] + } + def Cube "BladeB" + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.8, 0.8, 0.82)] + double3 xformOp:scale = (0.12, 0.9, 0.04) + uniform token[] xformOpOrder = ["xformOp:scale"] + } + } + + # Status indicator whose emissive colour is driven by differential + # pressure at runtime (glows in an RTX renderer). + def Xform "StatusLight" + { + double3 xformOp:translate = (0.6, 0, 0.6) + uniform token[] xformOpOrder = ["xformOp:translate"] + + # Driven by a UaAlarmToUsd binding: the supervision alarm + # ActiveState toggles this prim's visibility ("inherited" when + # active, "invisible" when clear). + token visibility = "inherited" + + def Sphere "Bulb" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double radius = 0.15 + rel material:binding = + } + + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.1, 0.1, 0.1) + color3f inputs:emissiveColor = (0, 0, 0) + token outputs:surface + } + } + } + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/Assets/pump.usda b/tests/Opc.Ua.OpenUsd.Tests/Assets/pump.usda new file mode 100644 index 0000000000..07059c62b8 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/Assets/pump.usda @@ -0,0 +1,37 @@ +#usda 1.0 +( + doc = """Reusable single-pump USD asset for the OPC UA - OpenUSD Bindings + composition demo. A ProductionLine aggregates 1..n pumps by *referencing* + (and instancing) this asset's default prim once per aggregated pump + (spec ยง5.12, CompositionArc = Instance/Reference). The cross-server component + references an equivalent OEM asset (remote-pump.usda) on another server (ยง5.14). + + This is the component asset the connector's authored reference/instance prims + point at; it carries only geometry so many instances compose cheaply. + """ + defaultPrim = "Pump" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Pump" ( + kind = "component" +) +{ + def Cylinder "Body" + { + uniform token axis = "Z" + double height = 1.0 + double radius = 0.5 + color3f[] primvars:displayColor = [(0.6, 0.6, 0.65)] + double3 xformOp:translate = (0, 0, 0.5) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Impeller" + { + double xformOp:rotateZ = 0 + double3 xformOp:translate = (0, 0, 1.05) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateZ"] + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/Assets/robot.usda b/tests/Opc.Ua.OpenUsd.Tests/Assets/robot.usda new file mode 100644 index 0000000000..d03d7c9187 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/Assets/robot.usda @@ -0,0 +1,157 @@ +#usda 1.0 +( + doc = """Reusable 6-axis articulated robot asset for the OPC UA - OpenUSD Bindings Robotics example. + + The nested Xforms are the kinematic contract declared in Robotics.OpenUsdBinding.json. Each J{i} prim carries the rotate op driven by that Axis' ActualPosition (degrees). The asset contains only static geometry and home-pose defaults; live joint values are authored by live.usda. + """ + defaultPrim = "Robot" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Robot" ( + kind = "component" +) +{ + def Xform "Base" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + color3f[] primvars:displayColor = [(0.0, 0.55, 0.18)] + rel material:binding = + + def Cylinder "Pedestal" + { + uniform token axis = "Z" + double height = 0.18 + double radius = 0.28 + color3f[] primvars:displayColor = [(0.0, 0.55, 0.18)] + double3 xformOp:translate = (0, 0, 0.09) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "J1" + { + double3 xformOp:translate = (0, 0, 0.18) + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateZ"] + + def Cylinder "Column" + { + uniform token axis = "Z" + double height = 0.45 + double radius = 0.12 + color3f[] primvars:displayColor = [(0.72, 0.72, 0.76)] + double3 xformOp:translate = (0, 0, 0.225) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "J2" + { + double3 xformOp:translate = (0, 0, 0.45) + double xformOp:rotateY = -30 + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateY"] + + def Cube "UpperArm" + { + double size = 1 + color3f[] primvars:displayColor = [(0.88, 0.62, 0.18)] + double3 xformOp:translate = (0.35, 0, 0) + double3 xformOp:scale = (0.35, 0.07, 0.07) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Xform "J3" + { + double3 xformOp:translate = (0.70, 0, 0) + double xformOp:rotateY = 45 + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateY"] + + def Cube "Forearm" + { + double size = 1 + color3f[] primvars:displayColor = [(0.86, 0.50, 0.14)] + double3 xformOp:translate = (0.28, 0, 0) + double3 xformOp:scale = (0.28, 0.055, 0.055) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Xform "J4" + { + double3 xformOp:translate = (0.56, 0, 0) + double xformOp:rotateX = 0 + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateX"] + + def Cylinder "WristRoll" + { + uniform token axis = "X" + double height = 0.22 + double radius = 0.06 + color3f[] primvars:displayColor = [(0.78, 0.78, 0.82)] + } + + def Xform "J5" + { + double3 xformOp:translate = (0.22, 0, 0) + double xformOp:rotateY = 60 + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateY"] + + def Cube "WristBend" + { + double size = 1 + color3f[] primvars:displayColor = [(0.82, 0.82, 0.86)] + double3 xformOp:scale = (0.10, 0.05, 0.05) + uniform token[] xformOpOrder = ["xformOp:scale"] + } + + def Xform "J6" + { + double3 xformOp:translate = (0.20, 0, 0) + double xformOp:rotateX = 0 + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateX"] + + def Cylinder "FlangeDisc" + { + uniform token axis = "X" + double height = 0.08 + double radius = 0.09 + color3f[] primvars:displayColor = [(0.25, 0.25, 0.28)] + } + + def Xform "Flange" + { + double3 xformOp:translate = (0.08, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + } + } + } + } + } + } + + def Sphere "Warning" + { + token visibility = "invisible" + double radius = 0.36 + color3f[] primvars:displayColor = [(1, 0, 0)] + double3 xformOp:translate = (0, 0, 0.42) + double3 xformOp:scale = (1, 1, 0.08) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Scope "Materials" + { + def Material "BaseGreen" + { + token outputs:surface.connect = + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.0, 0.55, 0.18) + token outputs:surface + } + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/Assets/tool.usda b/tests/Opc.Ua.OpenUsd.Tests/Assets/tool.usda new file mode 100644 index 0000000000..db0dbe3cf1 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/Assets/tool.usda @@ -0,0 +1,39 @@ +#usda 1.0 +( + doc = "Simple gripper tool dynamically referenced on R1's flange by the Robotics OpenUSD example." + defaultPrim = "Gripper" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Gripper" ( + kind = "component" +) +{ + def Cube "Body" + { + double size = 1 + color3f[] primvars:displayColor = [(0.18, 0.20, 0.22)] + double3 xformOp:translate = (0.08, 0, 0) + double3 xformOp:scale = (0.08, 0.12, 0.06) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "JawUpper" + { + double size = 1 + color3f[] primvars:displayColor = [(0.05, 0.05, 0.06)] + double3 xformOp:translate = (0.20, 0.08, 0) + double3 xformOp:scale = (0.12, 0.025, 0.025) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "JawLower" + { + double size = 1 + color3f[] primvars:displayColor = [(0.05, 0.05, 0.06)] + double3 xformOp:translate = (0.20, -0.08, 0) + double3 xformOp:scale = (0.12, 0.025, 0.025) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/CompositeUsdSinkTests.cs b/tests/Opc.Ua.OpenUsd.Tests/CompositeUsdSinkTests.cs new file mode 100644 index 0000000000..e98d91af40 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/CompositeUsdSinkTests.cs @@ -0,0 +1,308 @@ +/* ======================================================================== + * 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 NUnit.Framework; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Unit tests for , the fan-out the connector uses when + /// it must drive an override layer and a live viewport from one subscription. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class CompositeUsdSinkTests + { + [Test] + public void SetAttributeReachesEverySink() + { + var first = new MockUsdSink(); + var second = new MockUsdSink(); + var sink = new CompositeUsdSink(first, second); + + sink.SetAttribute("/Cell/Robots/R1", "xformOp:rotateZ", new Variant(45.0)); + + Assert.That(first.WasWritten("/Cell/Robots/R1", "xformOp:rotateZ"), Is.True); + Assert.That(second.WasWritten("/Cell/Robots/R1", "xformOp:rotateZ"), Is.True); + } + + [Test] + public void SetTimeSampleReachesEverySink() + { + var first = new MockUsdSink(); + var second = new MockUsdSink(); + var sink = new CompositeUsdSink(first, second); + + sink.SetTimeSample( + "/Cell", + "custom:temperature", + new DateTime(1970, 1, 1, 0, 0, 5, DateTimeKind.Utc), + new Variant(21.5)); + + Assert.That(first.TimeSampleWrites, Is.EqualTo(1)); + Assert.That(second.TimeSampleWrites, Is.EqualTo(1)); + } + + [Test] + public void ComposePrimReachesEverySink() + { + var first = new MockUsdSink(); + var second = new MockUsdSink(); + var sink = new CompositeUsdSink(first, second); + + sink.ComposePrim( + "/Cell/Robots/R1", OpenUsdCompositionArc.Reference, + "@robot.usda@", active: true); + + Assert.That(first.WasPrimComposed("/Cell/Robots/R1"), Is.True); + Assert.That(second.WasPrimComposed("/Cell/Robots/R1"), Is.True); + Assert.That(second.IsPrimActive("/Cell/Robots/R1"), Is.True); + } + + [Test] + public void BeginBatchOpensAndClosesEveryInnerScope() + { + var first = new RecordingSink(); + var second = new RecordingSink(); + var sink = new CompositeUsdSink(first, second); + + IDisposable scope = sink.BeginBatch(); + Assert.That(first.OpenBatches, Is.EqualTo(1)); + Assert.That(second.OpenBatches, Is.EqualTo(1)); + + scope.Dispose(); + Assert.That(first.OpenBatches, Is.Zero); + Assert.That(second.OpenBatches, Is.Zero); + } + + [Test] + public void BatchScopeDisposalIsIdempotent() + { + var inner = new RecordingSink(); + var sink = new CompositeUsdSink(inner); + + IDisposable scope = sink.BeginBatch(); + scope.Dispose(); + scope.Dispose(); + + Assert.That(inner.ClosedBatches, Is.EqualTo(1)); + } + + [Test] + public void AFailingSinkDoesNotStopTheOthers() + { + var failing = new ThrowingSink(); + var healthy = new MockUsdSink(); + var sink = new CompositeUsdSink(failing, healthy); + + Assert.That( + () => sink.SetAttribute("/Cell", "visibility", new Variant("inherited")), + Throws.InvalidOperationException); + Assert.That(healthy.WasWritten("/Cell", "visibility"), Is.True); + } + + [Test] + public void AFailingSinkRethrowsTheFirstFailure() + { + var first = new ThrowingSink { Message = "first" }; + var second = new ThrowingSink { Message = "second" }; + var sink = new CompositeUsdSink(first, second); + + Assert.That( + () => sink.ComposePrim("/Cell", OpenUsdCompositionArc.Child, null, active: true), + Throws.InvalidOperationException.With.Message.EqualTo("first")); + } + + [Test] + public void AFailingTimeSampleSinkDoesNotStopTheOthers() + { + var failing = new ThrowingSink { Message = "sampler down" }; + var healthy = new MockUsdSink(); + var sink = new CompositeUsdSink(failing, healthy); + + Assert.That( + () => sink.SetTimeSample( + "/Cell", + "custom:temperature", + new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc), + new Variant(21.5)), + Throws.InvalidOperationException.With.Message.EqualTo("sampler down")); + Assert.That(healthy.TimeSampleWrites, Is.EqualTo(1)); + } + + [Test] + public void ABatchScopeClosesEveryInnerScopeEvenWhenOneThrowsOnDispose() + { + var failing = new ThrowingSink { FailOnScopeDispose = true, Message = "scope down" }; + var healthy = new RecordingSink(); + var sink = new CompositeUsdSink(failing, healthy); + + IDisposable scope = sink.BeginBatch(); + + // The healthy sink is disposed first (reverse order) and the failure is rethrown + // only once every inner scope has been given the chance to close. + Assert.That( + () => scope.Dispose(), + Throws.InvalidOperationException.With.Message.EqualTo("scope down")); + Assert.That(healthy.ClosedBatches, Is.EqualTo(1)); + Assert.That(healthy.OpenBatches, Is.Zero); + } + + [Test] + public void ConstructorRejectsNoSinks() + { + Assert.That(() => new CompositeUsdSink(), Throws.ArgumentException); + } + + [Test] + public void ConstructorRejectsANullSink() + { + Assert.That( + () => new CompositeUsdSink(new MockUsdSink(), null!), + Throws.ArgumentException); + } + + [Test] + public void ConstructorRejectsANullArray() + { + Assert.That(() => new CompositeUsdSink(null!), Throws.ArgumentNullException); + } + + [Test] + public void LaterSinksStillSeeValuesAfterAnEarlierBatchFailure() + { + var failing = new ThrowingSink { FailOnBatch = true }; + var healthy = new RecordingSink(); + var sink = new CompositeUsdSink(failing, healthy); + + Assert.That(() => sink.BeginBatch(), Throws.InvalidOperationException); + // The healthy sink's scope was never opened, so nothing is left dangling. + Assert.That(healthy.OpenBatches, Is.Zero); + } + + private sealed class RecordingSink : IUsdSink + { + private readonly List m_scopes = []; + + public int OpenBatches { get; private set; } + + public int ClosedBatches { get; private set; } + + public void SetAttribute(string primPath, string propertyName, Variant value) + { + } + + public void SetTimeSample( + string primPath, string propertyName, DateTime time, Variant value) + { + } + + public void ComposePrim( + string primPath, OpenUsdCompositionArc arc, string? assetReference, bool active) + { + } + + public IDisposable BeginBatch() + { + OpenBatches++; + var scope = new Scope(this); + m_scopes.Add(scope); + return scope; + } + + private sealed class Scope : IDisposable + { + private readonly RecordingSink m_owner; + private bool m_disposed; + + public Scope(RecordingSink owner) + { + m_owner = owner; + } + + public void Dispose() + { + if (m_disposed) + { + return; + } + m_disposed = true; + m_owner.OpenBatches--; + m_owner.ClosedBatches++; + } + } + } + + private sealed class ThrowingSink : IUsdSink + { + public string Message { get; init; } = "sink failed"; + + public bool FailOnBatch { get; init; } + + public bool FailOnScopeDispose { get; init; } + + public void SetAttribute(string primPath, string propertyName, Variant value) => + throw new InvalidOperationException(Message); + + public void SetTimeSample( + string primPath, string propertyName, DateTime time, Variant value) => + throw new InvalidOperationException(Message); + + public void ComposePrim( + string primPath, OpenUsdCompositionArc arc, string? assetReference, bool active) => + throw new InvalidOperationException(Message); + + public IDisposable BeginBatch() => + FailOnBatch + ? throw new InvalidOperationException(Message) + : FailOnScopeDispose ? new ThrowingScope(Message) : new NoOpScope(); + + private sealed class NoOpScope : IDisposable + { + public void Dispose() + { + } + } + + private sealed class ThrowingScope : IDisposable + { + private readonly string m_message; + + public ThrowingScope(string message) + { + m_message = message; + } + + public void Dispose() => throw new InvalidOperationException(m_message); + } + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/CompositionAndSchemaTests.cs b/tests/Opc.Ua.OpenUsd.Tests/CompositionAndSchemaTests.cs new file mode 100644 index 0000000000..f512f76830 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/CompositionAndSchemaTests.cs @@ -0,0 +1,154 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Composition provenance (ยง5.6) and applied API schemas (ยง8.2): the arcs that composed a + /// prim and the schemas applied to it are materialized as inspectable provenance. + /// + [TestFixture] + public class CompositionAndSchemaTests + { + // ---- Composition arcs (ยง5.6) --------------------------------------------------- + + [Test] + public void ReferenceAndInstanceArcs_AreMaterialized_ForReferencedPrim() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + + List arcs = ms.CompositionArcs("/Plant/Pumps/P101"); + Assert.That(arcs, Has.Count.EqualTo(2)); + + UsdCompositionArcState reference = + arcs.Single(a => a.ArcKind!.Value == UsdArcKindEnum.Reference); + Assert.That(reference.AssetPath!.Value, Is.EqualTo("pump.usda")); + Assert.That(reference.PrimPath!.Value, Is.EqualTo("/Pump")); + Assert.That(reference.ListPosition!.Value, Is.EqualTo(UsdListOpTypeEnum.Append)); + Assert.That(reference.ReferenceTypeId, Is.EqualTo(Opc.Ua.ReferenceTypeIds.HasComponent)); + + UsdCompositionArcState instance = + arcs.Single(a => a.ArcKind!.Value == UsdArcKindEnum.Instance); + Assert.That(instance.AssetPath!.Value, Is.EqualTo("pump.usda")); + Assert.That(instance.PrimPath!.Value, Is.EqualTo("/Pump")); + Assert.That(instance.ListPosition!.Value, Is.EqualTo(UsdListOpTypeEnum.Append)); + } + + [Test] + public void CompositionFolder_IsAbsentForNonComposedPrim() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + // A prim with no composition arcs gets no Composition folder. + Assert.That(ms.Prim("/Plant/Pumps/P101/Body").Composition, Is.Null); + Assert.That(ms.CompositionArcs("/Plant/Pumps/P101/Body"), Is.Empty); + } + + // ---- Variant sets (ยง5.6) ------------------------------------------------------- + + [Test] + public void VariantSet_IsMaterialized_WhenAuthored() + { + var stage = new UsdStage("V") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.VariantSets.Add(new UsdVariantSet("lod", "high")); + prim.Composition.Add(new UsdCompositionArc(UsdArcKindEnum.Reference) + { + AssetPath = "ext.usda", + PrimPath = "/Root", + ListPosition = UsdListOpTypeEnum.Append + }); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + FolderState variantSets = ms.Prim("/P").VariantSets!; + List sets = + MaterializationHarness.ChildrenOfType(ms.Context, variantSets); + Assert.That(sets, Has.Count.EqualTo(1)); + Assert.That(sets[0].SetName!.Value, Is.EqualTo("lod")); + Assert.That(sets[0].Selection!.Value, Is.EqualTo("high")); + + List arcs = ms.CompositionArcs("/P"); + Assert.That(arcs, Has.Count.EqualTo(1)); + Assert.That(arcs[0].ArcKind!.Value, Is.EqualTo(UsdArcKindEnum.Reference)); + Assert.That(arcs[0].AssetPath!.Value, Is.EqualTo("ext.usda")); + } + + [Test] + public void CompositionOff_SuppressesArcs() + { + MaterializedScene ms = MaterializationHarness.Materialize( + TestAssets.Load("Plant.usda"), + new Server.UsdMaterializationOptions { MaterializeComposition = false }); + + Assert.That(ms.Prim("/Plant/Pumps/P101").Composition, Is.Null); + } + + // ---- Applied schemas (ยง8.2) ---------------------------------------------------- + + [Test] + [TestCase("/Cell/Robots/R1/Base")] + [TestCase("/Cell/Robots/R2/Base")] + public void CollectionApi_MaterializesAsUsdCollectionApiType(string basePath) + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Cell.usda")); + + // Two schemas are applied: MaterialBindingAPI (generic) and CollectionAPI. + List all = ms.AppliedSchemas(basePath); + Assert.That(all, Has.Count.EqualTo(2)); + + List collections = + ms.AppliedSchemas(basePath); + Assert.That(collections, Has.Count.EqualTo(1)); + UsdCollectionAPIState collection = collections[0]; + Assert.That(collection.SchemaName!.Value, Is.EqualTo("CollectionAPI")); + // Applied schemas hang off the prim by HasAddIn. + Assert.That(collection.ReferenceTypeId, Is.EqualTo(Opc.Ua.ReferenceTypeIds.HasAddIn)); + + Assert.That( + all.Any(s => s.SchemaName!.Value == "MaterialBindingAPI"), + Is.True); + } + + [Test] + public void AppliedSchemasOff_SuppressesFolder() + { + MaterializedScene ms = MaterializationHarness.Materialize( + TestAssets.Load("Cell.usda"), + new Server.UsdMaterializationOptions { MaterializeAppliedSchemas = false }); + + Assert.That(ms.Prim("/Cell/Robots/R1/Base").AppliedSchemas, Is.Null); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConnectionFidelityTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConnectionFidelityTests.cs new file mode 100644 index 0000000000..388273c8ff --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/ConnectionFidelityTests.cs @@ -0,0 +1,235 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Attribute-connection fidelity across materializeโ†’export (ยง5.4, ยง7.4): + /// + /// M-2 โ€” the exported connection sequence equals the authored order, recovered from + /// the materialization result's side channel rather than from hash-ordered references. + /// M-5 โ€” a .connect whose target lies outside the materialized subtree (and so + /// has no browsable edge) still survives the round trip, the connection counterpart of a + /// relationship's TargetPaths. + /// M-4 โ€” an attribute co-authored with both a default value and a connection reports + /// both after export; neither is discarded. + /// + /// + [TestFixture] + public class ConnectionFidelityTests + { + // ---- M-2: exported connection order equals authored order ---------------------- + + [Test] + public void Export_TwoConnections_PreservesAuthoredOrder() + { + // Author the two targets in reverse-alphabetical order so authored order (b, a) is + // distinguishable from any incidental sort or hash order (which would give a, b). + var stage = new UsdStage("S") { DefaultPrim = "Sink" }; + var src = new UsdPrim("Src", "Xform"); + src.Attributes.Add(new UsdAttribute("a", "double") { Value = 1.0 }); + src.Attributes.Add(new UsdAttribute("b", "double") { Value = 2.0 }); + var sink = new UsdPrim("Sink", "Xform"); + var input = new UsdAttribute("in", "double"); + input.Connections.Add("/Src.b"); + input.Connections.Add("/Src.a"); + sink.Attributes.Add(input); + stage.AddRootPrim(src); + stage.AddRootPrim(sink); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + // The result overload carries the recorded authored order (M-2 fix). + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + + UsdAttribute exportedInput = AttributeOf(exported, "Sink", "in"); + Assert.That(exportedInput.Connections, Is.EqualTo(new[] { "/Src.b", "/Src.a" }), + "Exported connections must be in the authored order, not reference/hash order."); + } + + [Test] + public void Materialize_RecordsAuthoredConnectionOrder_OnResult() + { + // The mechanism that makes M-2/M-5 deterministic: the authored connection paths are + // snapshotted verbatim onto the materialization result, keyed by attribute node. + var stage = new UsdStage("S") { DefaultPrim = "Sink" }; + var src = new UsdPrim("Src", "Xform"); + src.Attributes.Add(new UsdAttribute("a", "double") { Value = 1.0 }); + src.Attributes.Add(new UsdAttribute("b", "double") { Value = 2.0 }); + var sink = new UsdPrim("Sink", "Xform"); + var input = new UsdAttribute("in", "double"); + input.Connections.Add("/Src.b"); + input.Connections.Add("/Src.a"); + sink.Attributes.Add(input); + stage.AddRootPrim(src); + stage.AddRootPrim(sink); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + UsdAttributeState node = ms.Attr("/Sink.in"); + Assert.That(ms.Result.ConnectionsByNode.ContainsKey(node), Is.True, + "A connected attribute must be recorded on the result."); + Assert.That(ms.Result.ConnectionsByNode[node], + Is.EqualTo(new[] { "/Src.b", "/Src.a" }), + "The recorded order must be the authored order."); + } + + [Test] + public void Materialize_TwoConnectionsFromOneAttribute_DoesNotThrow() + { + // Regression guard: materialized attributes share the model's placeholder NodeId + // (xUsdAttribute_, i=6023), so two connections authored on one attribute resolve to + // the same target NodeId. The browsable-edge builder must dedupe by target NodeId + // rather than throwing a duplicate-reference exception. The lossless authored order is + // preserved separately on the result (asserted by the tests above). + var stage = new UsdStage("S") { DefaultPrim = "Sink" }; + var src = new UsdPrim("Src", "Xform"); + src.Attributes.Add(new UsdAttribute("a", "double") { Value = 1.0 }); + src.Attributes.Add(new UsdAttribute("b", "double") { Value = 2.0 }); + var sink = new UsdPrim("Sink", "Xform"); + var input = new UsdAttribute("in", "double"); + input.Connections.Add("/Src.b"); + input.Connections.Add("/Src.a"); + sink.Attributes.Add(input); + stage.AddRootPrim(src); + stage.AddRootPrim(sink); + + MaterializedScene ms = null!; + Assert.DoesNotThrow( + () => ms = MaterializationHarness.Materialize(stage), + "Two resolvable connections sharing a target NodeId must not throw."); + Assert.That(ms.Result.ConnectionsByNode[ms.Attr("/Sink.in")], + Is.EqualTo(new[] { "/Src.b", "/Src.a" }), + "Both authored connections must still be recorded despite the deduped edge."); + } + + [Test] + public void Export_UnresolvableConnectionTarget_Survives() + { + // The target lies outside the materialized subtree, so no browsable UsdConnection edge + // exists for it. The recorded authored path is the only way it can round-trip. + var stage = new UsdStage("S") { DefaultPrim = "Sink" }; + var sink = new UsdPrim("Sink", "Xform"); + var input = new UsdAttribute("in", "double"); + input.Connections.Add("/Outside/Elsewhere.output"); + sink.Attributes.Add(input); + stage.AddRootPrim(sink); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + + UsdAttribute exportedInput = AttributeOf(exported, "Sink", "in"); + Assert.That(exportedInput.Connections, + Is.EqualTo(new[] { "/Outside/Elsewhere.output" }), + "An unresolvable .connect target must not be dropped (ยง8.4 shall-not-drop)."); + } + + [Test] + public void Export_MixedResolvableAndUnresolvable_PreservesBothInOrder() + { + // One target resolves inside the subtree, one does not; both must appear, in the exact + // authored order, proving the side channel โ€” not the edges โ€” drives the export. + var stage = new UsdStage("S") { DefaultPrim = "Sink" }; + var src = new UsdPrim("Src", "Xform"); + src.Attributes.Add(new UsdAttribute("out", "double") { Value = 1.0 }); + var sink = new UsdPrim("Sink", "Xform"); + var input = new UsdAttribute("in", "double"); + input.Connections.Add("/Missing.target"); + input.Connections.Add("/Src.out"); + sink.Attributes.Add(input); + stage.AddRootPrim(src); + stage.AddRootPrim(sink); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + + UsdAttribute exportedInput = AttributeOf(exported, "Sink", "in"); + Assert.That(exportedInput.Connections, + Is.EqualTo(new[] { "/Missing.target", "/Src.out" }), + "Both the unresolvable and the resolvable target must survive, in authored order."); + } + + // ---- M-4: a value co-authored with a connection is not discarded --------------- + + [Test] + public void Export_AttributeWithValueAndConnection_ReportsBoth() + { + // USD permits an attribute to carry both a default value and a connection; the + // materializer must keep the value while the connection is recorded, and the exporter + // must report both independently (ยง5.4, ยง7.2). + var stage = new UsdStage("S") { DefaultPrim = "Sink" }; + var src = new UsdPrim("Src", "Xform"); + src.Attributes.Add(new UsdAttribute("out", "double") { Value = 3.0 }); + var sink = new UsdPrim("Sink", "Xform"); + var input = new UsdAttribute("in", "double") { Value = 1.5 }; + input.Connections.Add("/Src.out"); + sink.Attributes.Add(input); + stage.AddRootPrim(src); + stage.AddRootPrim(sink); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + // The materialized node keeps its default value even though it is connected. + Assert.That(ms.Attr("/Sink.in").BoxedValue(), Is.EqualTo(1.5), + "The materializer must not discard the value when a connection is present."); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + UsdAttribute exportedInput = AttributeOf(exported, "Sink", "in"); + Assert.That(exportedInput.Value, Is.EqualTo(1.5), + "The exported attribute must still report its default value."); + Assert.That(exportedInput.Connections, Is.EqualTo(new[] { "/Src.out" }), + "The exported attribute must also report its connection."); + } + + private static UsdAttribute AttributeOf(UsdStage stage, string primName, string attributeName) + { + foreach (UsdPrim prim in stage.RootPrims) + { + if (!string.Equals(prim.Name, primName, StringComparison.Ordinal)) + { + continue; + } + foreach (UsdAttribute attribute in prim.Attributes) + { + if (string.Equals(attribute.Name, attributeName, StringComparison.Ordinal)) + { + return attribute; + } + } + } + Assert.Fail($"Attribute {primName}.{attributeName} was not found in the exported stage."); + return new UsdAttribute(attributeName, string.Empty); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConnectionPathsMemberTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConnectionPathsMemberTests.cs new file mode 100644 index 0000000000..d70befcb4a --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/ConnectionPathsMemberTests.cs @@ -0,0 +1,149 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Connections carry their authored SdfPaths on the attribute node itself, in + /// ConnectionPaths โ€” the counterpart of a relationship's TargetPaths + /// (draft OPC UA โ€” OpenUSD Scene Materialization ยง5.4, ยง5.5, ยง7.4). + /// + /// + /// These cases pin the properties the browsable UsdConnection edges alone cannot + /// provide: materialized attributes deliberately share the model's placeholder NodeId, so the + /// edges are ambiguous and carry neither authored order nor a target outside the materialized + /// subtree. They also cover the bare export overload โ€” the one with no side channel โ€” + /// because that is where the loss used to occur. + /// + [TestFixture] + public class ConnectionPathsMemberTests + { + private static UsdStage BuildStage(params string[] connections) + { + var stage = new UsdStage("Cs"); + var root = new UsdPrim("Root", "Xform"); + var shader = new UsdPrim("Shader", "Shader"); + var a = new UsdAttribute("outputs:a", "token"); + var b = new UsdAttribute("outputs:b", "token"); + var sink = new UsdAttribute("inputs:sink", "token"); + foreach (string c in connections) + { + sink.Connections.Add(c); + } + shader.Attributes.Add(a); + shader.Attributes.Add(b); + shader.Attributes.Add(sink); + root.AddChild(shader); + stage.AddRootPrim(root); + return stage; + } + + private static UsdAttribute ExportedSink(UsdStage exported) + { + return exported.AllPrims() + .SelectMany(p => p.Attributes) + .Single(a => a.Name == "inputs:sink"); + } + + [Test] + public void ConnectionPaths_IsAuthoredOnTheNode() + { + MaterializedScene ms = MaterializationHarness.Materialize( + BuildStage("/Root/Shader.outputs:a", "/Root/Shader.outputs:b")); + + UsdAttributeState node = ms.Result.AttributesByPath["/Root/Shader.inputs:sink"]; + Assert.That(node.ConnectionPaths, Is.Not.Null); + ArrayOf paths = node.ConnectionPaths!.Value; + + Assert.That(paths.ToArray(), Is.EqualTo(new[] + { + "/Root/Shader.outputs:a", + "/Root/Shader.outputs:b" + })); + } + + [Test] + public void BareOverload_RecoversAuthoredOrder() + { + // The bare overload has no side channel; before ConnectionPaths existed it could only + // read the ambiguous edges and could not reproduce the authored sequence. + MaterializedScene ms = MaterializationHarness.Materialize( + BuildStage("/Root/Shader.outputs:b", "/Root/Shader.outputs:a")); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Result.Stage); + + Assert.That(ExportedSink(exported).Connections, Is.EqualTo(new[] + { + "/Root/Shader.outputs:b", + "/Root/Shader.outputs:a" + })); + } + + [Test] + public void BareOverload_KeepsTargetOutsideTheMaterializedSubtree() + { + // No node exists for this path, so there is no browsable edge to rebuild it from. + MaterializedScene ms = MaterializationHarness.Materialize( + BuildStage("/Elsewhere/Other.outputs:surface")); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Result.Stage); + + Assert.That(ExportedSink(exported).Connections, + Is.EqualTo(new[] { "/Elsewhere/Other.outputs:surface" })); + } + + [Test] + public void BareOverload_KeepsDuplicateTargets() + { + // Deduped as browsable edges, but the authored multiplicity must survive. + MaterializedScene ms = MaterializationHarness.Materialize( + BuildStage("/Root/Shader.outputs:a", "/Root/Shader.outputs:a")); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Result.Stage); + + Assert.That(ExportedSink(exported).Connections, Has.Count.EqualTo(2)); + } + + [Test] + public void AttributeWithoutConnections_ExportsNone() + { + MaterializedScene ms = MaterializationHarness.Materialize(BuildStage()); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Result.Stage); + + Assert.That(ExportedSink(exported).Connections, Is.Empty); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs new file mode 100644 index 0000000000..38b98288ea --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionAsymmetryTests.cs @@ -0,0 +1,225 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Regression tests for the reader/writer asymmetry the H4/M writer fixes introduced: the + /// writer emits a multi-target .connect as a bracketed path-reference list and an + /// asset[] as a bracketed @โ€ฆ@ list, and the reader must now parse both so the + /// writer's own output round-trips under the ยง7.4 signature contract. + /// + [TestFixture] + public class ConversionAsymmetryTests + { + private static string Wrap(string primBody) + { + return string.Join("\n", + "#usda 1.0", + "(", + " defaultPrim = \"P\"", + " metersPerUnit = 1.0", + " upAxis = \"Z\"", + ")", + string.Empty, + "def Xform \"P\"", + "{", + primBody, + "}", + string.Empty); + } + + private static UsdAttribute AttributeNamed(UsdStage stage, string primPath, string name) + { + UsdPrim prim = stage.Find(primPath)!; + return prim.Attributes.First(a => a.Name == name); + } + + private static void AssertRoundTripSignature(UsdStage expected) + { + string written = UsdaWriter.Write(expected); + UsdStage reparsed = UsdaReader.Parse(written, expected.StageName); + + string? difference = UsdSceneSignature.FirstDifference(expected, reparsed); + Assert.That( + UsdSceneSignature.Compute(reparsed), + Is.EqualTo(UsdSceneSignature.Compute(expected)), + difference ?? "signatures are unexpectedly equal"); + } + + // ---- Task 1.1: multiple connection targets ------------------------------------ + + [Test] + public void ParseValue_BracketedPathReferenceList_ParsesEachTarget() + { + object? value = UsdaReader.ParseValue("[

,

]"); + + Assert.That(value, Is.EqualTo(new List + { + "/P/A.outputs:surface", + "/P/B.outputs:surface", + })); + } + + [Test] + public void TwoConnectionAttribute_ParsesBothTargetsInOrder() + { + UsdStage stage = UsdaReader.Parse( + Wrap(" token inputs:surface.connect = [

,

]"), + "Conn"); + + UsdAttribute attr = AttributeNamed(stage, "/P", "inputs:surface"); + Assert.That(attr.Connections, Is.EqualTo(new[] + { + "/P/A.outputs:surface", + "/P/B.outputs:surface", + })); + } + + [Test] + public void TwoConnectionAttribute_RoundTripsThroughWriteAndReparse() + { + UsdStage stage = UsdaReader.Parse( + Wrap(" token inputs:surface.connect = [

,

]"), + "Conn"); + + AssertRoundTripSignature(stage); + } + + [Test] + public void SingleConnectionAttribute_StillParsesAsOneTarget() + { + UsdStage stage = UsdaReader.Parse( + Wrap(" token inputs:surface.connect =

"), + "Conn"); + + UsdAttribute attr = AttributeNamed(stage, "/P", "inputs:surface"); + Assert.That(attr.Connections, Is.EqualTo(new[] { "/P/A.outputs:surface" })); + } + + [Test] + public void WriterMultiConnectionOutput_ReparsesToTheSameTargets() + { + // Reproduces the asymmetry directly: an in-memory attribute with two connections is + // written (as a bracketed list) and must re-parse to the same two connections. + var stage = new UsdStage("Conn") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + var attr = new UsdAttribute("inputs:surface", "token"); + attr.Connections.Add("/P/A.outputs:surface"); + attr.Connections.Add("/P/B.outputs:surface"); + prim.Attributes.Add(attr); + stage.AddRootPrim(prim); + + UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); + + UsdAttribute reparsedAttr = AttributeNamed(reparsed, "/P", "inputs:surface"); + Assert.That(reparsedAttr.Connections, Is.EqualTo(new[] + { + "/P/A.outputs:surface", + "/P/B.outputs:surface", + })); + } + + [Test] + public void EmptyConnectionList_ParsesToNoTargets() + { + UsdStage stage = UsdaReader.Parse( + Wrap(" token inputs:surface.connect = []"), + "Conn"); + + UsdAttribute attr = AttributeNamed(stage, "/P", "inputs:surface"); + Assert.That(attr.Connections, Is.Empty); + } + + // ---- Task 1.2: asset arrays --------------------------------------------------- + + [Test] + public void ParseValue_AssetArray_ParsesEachElementUnwrapped() + { + object? value = UsdaReader.ParseValue("[@./a.usda@, @./b.usda@]"); + + Assert.That(value, Is.EqualTo(new List { "./a.usda", "./b.usda" })); + } + + [Test] + public void AssetArrayAttribute_ParsesToUnwrappedPaths() + { + UsdStage stage = UsdaReader.Parse( + Wrap(" custom asset[] inputs:files = [@./a.usda@, @./b.usda@]"), + "Assets"); + + UsdAttribute attr = AttributeNamed(stage, "/P", "inputs:files"); + Assert.That(attr.Value, Is.EqualTo(new List { "./a.usda", "./b.usda" })); + } + + [Test] + public void AssetArrayAttribute_RoundTripsThroughWriteAndReparse() + { + UsdStage stage = UsdaReader.Parse( + Wrap(" custom asset[] inputs:files = [@./a.usda@, @./b.usda@]"), + "Assets"); + + AssertRoundTripSignature(stage); + } + + [Test] + public void WriterAssetArrayOutput_ReparsesToTheSamePaths() + { + var stage = new UsdStage("Assets") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add(new UsdAttribute("inputs:files", "asset[]") + { + Value = new List { "./a.usda", "./b.usda" }, + }); + stage.AddRootPrim(prim); + + UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); + + UsdAttribute reparsedAttr = AttributeNamed(reparsed, "/P", "inputs:files"); + Assert.That(reparsedAttr.Value, Is.EqualTo(new List { "./a.usda", "./b.usda" })); + } + + [Test] + public void SingleAssetScalar_StillParsesUnwrapped() + { + UsdStage stage = UsdaReader.Parse( + Wrap(" asset inputs:file = @./pump.usda@"), + "Assets"); + + UsdAttribute attr = AttributeNamed(stage, "/P", "inputs:file"); + Assert.That(attr.Value, Is.EqualTo("./pump.usda")); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs new file mode 100644 index 0000000000..ced721ff6e --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionEmitPathTests.cs @@ -0,0 +1,437 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Regression tests that compose the full export path โ€” build a stage, serialise it with + /// , then assert the emitted .usda text and/or re-parse and + /// compare with . This is the path the verification review found + /// no existing test exercised, which is why the H-1 array-emit regression survived: after the + /// document model was corrected, hands the writer an + /// object?[] for every array, and the writer keyed its array rendering on the CLR + /// container (List<object?>) instead of the attribute type, so every exported array + /// fell through to the scalar/tuple path and was corrupted into a parenthesised tuple. + /// + [TestFixture] + public class ConversionEmitPathTests + { + private static string EmitRootAttribute(string primType, UsdAttribute attribute) + { + var stage = new UsdStage("Assets") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", primType); + prim.Attributes.Add(attribute); + stage.AddRootPrim(prim); + return UsdaWriter.Write(stage); + } + + private static void AssertRoundTripSignature(UsdStage expected) + { + string written = UsdaWriter.Write(expected); + UsdStage reparsed = UsdaReader.Parse(written, expected.StageName); + + string? difference = UsdSceneSignature.FirstDifference(expected, reparsed); + Assert.That( + UsdSceneSignature.Compute(reparsed), + Is.EqualTo(UsdSceneSignature.Compute(expected)), + difference ?? "signatures are unexpectedly equal"); + } + + // ---- H-1: an exported array is emitted as "[...]", never a parenthesised tuple ---- + + [Test] + public void TokenArray_FromDecoercedShape_EmitsBracketedDoubleQuotedElements() + { + // UsdValueCoercion.Decoerce hands the writer an object?[] for a token[] array. + object? decoerced = UsdValueCoercion.Decoerce(new string[] { "xformOp:translate" }); + + string usda = EmitRootAttribute( + "Xform", + new UsdAttribute("xformOpOrder", "token[]") + { + Variability = UsdVariabilityEnum.Uniform, + Value = decoerced, + }); + + Assert.That(usda, Does.Contain("uniform token[] xformOpOrder = [\"xformOp:translate\"]")); + // The H-1 corruption emitted a single-quoted parenthesised tuple instead of an array. + Assert.That(usda, Does.Not.Contain("('xformOp:translate')")); + Assert.That(usda, Does.Not.Contain("(\"xformOp:translate\")")); + } + + [Test] + public void TokenArray_FromObjectArrayShape_EmitsBracketedArray() + { + string usda = EmitRootAttribute( + "Xform", + new UsdAttribute("xformOpOrder", "token[]") + { + Variability = UsdVariabilityEnum.Uniform, + Value = new object?[] { "xformOp:translate", "xformOp:scale" }, + }); + + Assert.That( + usda, + Does.Contain("uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]")); + } + + [Test] + public void Color3fArray_FromDecoercedMatrix_EmitsBracketedTupleRows() + { + // A color3f[] materializes as a rectangular matrix; Decoerce regroups it into per-tuple + // rows carried in an object?[]. The writer must emit "[(...)]", not "((...))". + object? decoerced = UsdValueCoercion.Decoerce(new float[,] { { 0f, 0f, 1f } }); + + string usda = EmitRootAttribute( + "Mesh", + new UsdAttribute("primvars:displayColor", "color3f[]") { Value = decoerced }); + + Assert.That(usda, Does.Contain("color3f[] primvars:displayColor = [(0.0, 0.0, 1.0)]")); + Assert.That(usda, Does.Not.Contain("((0.0, 0.0, 1.0))")); + } + + [Test] + public void Color3fArray_FromObjectArrayShape_MultipleRows_EmitsEachTuple() + { + string usda = EmitRootAttribute( + "Mesh", + new UsdAttribute("primvars:displayColor", "color3f[]") + { + Value = new object?[] + { + new object?[] { 0.0, 0.0, 1.0 }, + new object?[] { 1.0, 1.0, 0.0 }, + }, + }); + + Assert.That( + usda, + Does.Contain("color3f[] primvars:displayColor = [(0.0, 0.0, 1.0), (1.0, 1.0, 0.0)]")); + } + + [Test] + public void AssetArray_FromDecoercedShape_EmitsAtDelimitedElements() + { + object? decoerced = UsdValueCoercion.Decoerce(new string[] { "./a.usda" }); + + string usda = EmitRootAttribute( + "Xform", + new UsdAttribute("inputs:files", "asset[]") { Value = decoerced }); + + Assert.That(usda, Does.Contain("asset[] inputs:files = [@./a.usda@]")); + // The H-1 corruption emitted a single-quoted parenthesised tuple, losing the @...@ form. + Assert.That(usda, Does.Not.Contain("('./a.usda')")); + Assert.That(usda, Does.Not.Contain("(\"./a.usda\")")); + } + + [Test] + public void Double3Scalar_IsStillEmittedAsParenthesisedTuple_NotArray() + { + // The type-name keying must not turn a fixed-size math scalar into an array: a double3 + // (ValueRank one-dimension, three components) is a single parenthesised tuple. + object? decoerced = UsdValueCoercion.Decoerce(new double[] { 1.0, 2.0, 3.0 }); + + string usda = EmitRootAttribute( + "Xform", + new UsdAttribute("xformOp:translate", "double3") { Value = decoerced }); + + Assert.That(usda, Does.Contain("double3 xformOp:translate = (1.0, 2.0, 3.0)")); + Assert.That(usda, Does.Not.Contain("[1.0, 2.0, 3.0]")); + } + + [Test] + public void FullEmitPath_ArraysAndConnection_RoundTripsUnderSignature() + { + // Composes the whole export path across every array shape plus a value co-authored with + // a connection, then re-parses and compares signatures (the missing coverage that let + // H-1 through, and the M-4 merge check in one). + var stage = new UsdStage("Assets") { DefaultPrim = "World" }; + var world = new UsdPrim("World", "Xform"); + + var mesh = new UsdPrim("Mesh", "Mesh"); + mesh.Attributes.Add(new UsdAttribute("xformOpOrder", "token[]") + { + Variability = UsdVariabilityEnum.Uniform, + Value = new object?[] { "xformOp:translate", "xformOp:scale" }, + }); + mesh.Attributes.Add(new UsdAttribute("primvars:displayColor", "color3f[]") + { + Value = new object?[] { new object?[] { 0.0, 0.0, 1.0 } }, + }); + mesh.Attributes.Add(new UsdAttribute("inputs:files", "asset[]") + { + Value = new object?[] { "./a.usda", "./b.usda" }, + }); + + var surface = new UsdAttribute("outputs:surface", "token") { Value = "fallback" }; + surface.Connections.Add("/World/Shader.outputs:surface"); + mesh.Attributes.Add(surface); + + world.AddChild(mesh); + stage.AddRootPrim(world); + + AssertRoundTripSignature(stage); + } + + // ---- M-4: a default value co-authored with a '.connect' merges into one attribute ---- + + [Test] + public void ValueAndConnect_OnOneAttribute_ReparseMergesIntoSingleAttribute() + { + var stage = new UsdStage("Conn") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + var attr = new UsdAttribute("inputs:surface", "token") { Value = "fallback" }; + attr.Connections.Add("/P/Shader.outputs:surface"); + prim.Attributes.Add(attr); + stage.AddRootPrim(prim); + + UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); + + UsdPrim prim2 = reparsed.Find("/P")!; + List matching = prim2.Attributes + .Where(a => a.Name == "inputs:surface").ToList(); + + Assert.That(matching, Has.Count.EqualTo(1), + "a value co-authored with a .connect must re-parse as one attribute, not two"); + Assert.That(matching[0].Value, Is.EqualTo("fallback")); + Assert.That(matching[0].Connections, Is.EqualTo(new[] { "/P/Shader.outputs:surface" })); + } + + [Test] + public void ValueSamplesAndConnect_AllCoalesceOntoOneAttribute() + { + var stage = new UsdStage("Conn") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + var attr = new UsdAttribute("xformOp:translate", "double3") + { + Value = new object?[] { 1.0, 2.0, 3.0 }, + }; + attr.TimeSamples[0.0] = new object?[] { 1.0, 2.0, 3.0 }; + attr.TimeSamples[24.0] = new object?[] { 4.0, 5.0, 6.0 }; + attr.Connections.Add("/P/Rig.outputs:translate"); + prim.Attributes.Add(attr); + stage.AddRootPrim(prim); + + UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); + + UsdPrim prim2 = reparsed.Find("/P")!; + List matching = prim2.Attributes + .Where(a => a.Name == "xformOp:translate").ToList(); + + Assert.That(matching, Has.Count.EqualTo(1), + "value, time samples and .connect on one attribute must not split into duplicates"); + Assert.That(matching[0].Connections, Is.EqualTo(new[] { "/P/Rig.outputs:translate" })); + Assert.That(matching[0].TimeSamples, Has.Count.EqualTo(2)); + Assert.That(matching[0].Value, Is.Not.Null); + } + + // ---- M-1: a known string/token attribute with a structured value fails closed ---- + + [Test] + public void KnownTokenArray_WithTupleElements_FailsClosed_NeverPublishesClrTypeName() + { + UsdValueTypeMapping mapping = UsdValueTypeMap.Map("token[]", null); + uint components = UsdValueTypeMap.ComponentCount("token[]"); + + bool ok = UsdValueCoercion.TryCoerce( + new object?[] { new object?[] { 1L, 2L }, new object?[] { 3L, 4L } }, + mapping, + components, + out Variant result); + + Assert.That(ok, Is.False, "a structured element cannot be a token string; fail closed"); + Assert.That(result.TryGetValue(out ArrayOf _), Is.False); + } + + [Test] + public void KnownStringScalar_WithTupleValue_FailsClosed() + { + UsdValueTypeMapping mapping = UsdValueTypeMap.Map("string", null); + uint components = UsdValueTypeMap.ComponentCount("string"); + + bool ok = UsdValueCoercion.TryCoerce( + new object?[] { 1L, 2L }, mapping, components, out Variant _); + + Assert.That(ok, Is.False); + } + + [Test] + public void KnownStringScalar_WithNumber_StillStringifiesFaithfully() + { + UsdValueTypeMapping mapping = UsdValueTypeMap.Map("string", null); + uint components = UsdValueTypeMap.ComponentCount("string"); + + bool ok = UsdValueCoercion.TryCoerce(42L, mapping, components, out Variant result); + + Assert.That(ok, Is.True); + Assert.That(result.TryGetValue(out string rendered), Is.True); + Assert.That(rendered, Is.EqualTo("42")); + Assert.That(rendered, Does.Not.Contain("System.")); + } + + [Test] + public void KnownTokenArray_WithStringElements_StillCoercesNormally() + { + UsdValueTypeMapping mapping = UsdValueTypeMap.Map("token[]", null); + uint components = UsdValueTypeMap.ComponentCount("token[]"); + + bool ok = UsdValueCoercion.TryCoerce( + new object?[] { "a", "b" }, mapping, components, out Variant result); + + Assert.That(ok, Is.True); + Assert.That(result.TryGetValue(out ArrayOf tokens), Is.True); + Assert.That(tokens.ToArray(), Is.EqualTo(new[] { "a", "b" })); + } + + // ---- M-3: a ')' inside a quoted metadata string must not truncate the block ---- + + [Test] + public void CloseParenInsideQuotedMetadataString_DoesNotTruncateBlock() + { + string usda = string.Join("\n", + "#usda 1.0", + "(", + " defaultPrim = \"P\"", + ")", + string.Empty, + "def Xform \"P\" (", + " comment = \"note with ) paren and ( too\"", + " kind = \"component\"", + ")", + "{", + "}", + string.Empty); + + UsdStage stage = UsdaReader.Parse(usda, "Meta"); + + UsdPrim prim = stage.Find("/P")!; + // The metadata after the ')'-bearing string survived (the block was not truncated). + Assert.That(prim.Kind, Is.EqualTo(UsdPrimKindEnum.Component)); + Assert.That(prim.Metadata.ContainsKey("comment"), Is.True); + Assert.That(prim.Metadata["comment"], Is.EqualTo("note with ) paren and ( too")); + } + + // ---- Defect 9: ยง6.3 custom prim metadata round-trips through Metadata ---- + + [Test] + public void CustomScalarMetadata_RoundTripsThroughMetadataDictionary() + { + var stage = new UsdStage("Meta") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Metadata["displayName"] = "Pump Assembly"; + prim.Metadata["revision"] = 3L; + prim.Metadata["approved"] = true; + prim.Metadata["tolerance"] = 0.25; + stage.AddRootPrim(prim); + + UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); + + UsdPrim prim2 = reparsed.Find("/P")!; + Assert.That(prim2.Metadata["displayName"], Is.EqualTo("Pump Assembly")); + Assert.That(prim2.Metadata["revision"], Is.EqualTo(3L)); + Assert.That(prim2.Metadata["approved"], Is.True); + Assert.That(prim2.Metadata["tolerance"], Is.EqualTo(0.25)); + } + + [Test] + public void CustomNestedDictionaryMetadata_RoundTripsAsNestedDictionary() + { + var stage = new UsdStage("Meta") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + var custom = new Dictionary(StringComparer.Ordinal) + { + ["author"] = "acme", + ["weight"] = 12.5, + ["count"] = 7L, + }; + prim.Metadata["customData"] = custom; + stage.AddRootPrim(prim); + + UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); + + UsdPrim prim2 = reparsed.Find("/P")!; + Assert.That(prim2.Metadata.ContainsKey("customData"), Is.True); + Assert.That(prim2.Metadata["customData"], Is.InstanceOf>()); + + var nested = (IDictionary)prim2.Metadata["customData"]!; + Assert.That(nested["author"], Is.EqualTo("acme")); + Assert.That(nested["weight"], Is.EqualTo(12.5)); + Assert.That(nested["count"], Is.EqualTo(7L)); + } + + [Test] + public void CustomMetadata_CoexistsWithWellKnownMetadata_WithoutPollution() + { + var stage = new UsdStage("Meta") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform") + { + Kind = UsdPrimKindEnum.Component, + Documentation = "A documented prim", + }; + prim.ApiSchemas.Add(new UsdApiSchema("PhysicsRigidBodyAPI")); + prim.Metadata["displayName"] = "Widget"; + stage.AddRootPrim(prim); + + UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); + + UsdPrim prim2 = reparsed.Find("/P")!; + // Well-known metadata still binds to typed members and is NOT duplicated as custom. + Assert.That(prim2.Kind, Is.EqualTo(UsdPrimKindEnum.Component)); + Assert.That(prim2.Documentation, Is.EqualTo("A documented prim")); + Assert.That(prim2.ApiSchemas.Select(a => a.SchemaName), Does.Contain("PhysicsRigidBodyAPI")); + Assert.That(prim2.Metadata.ContainsKey("displayName"), Is.True); + Assert.That(prim2.Metadata["displayName"], Is.EqualTo("Widget")); + Assert.That(prim2.Metadata.ContainsKey("kind"), Is.False); + Assert.That(prim2.Metadata.ContainsKey("doc"), Is.False); + Assert.That(prim2.Metadata.ContainsKey("apiSchemas"), Is.False); + } + + [Test] + public void CustomMetadata_WithCloseParenInStringValue_RoundTrips() + { + var stage = new UsdStage("Meta") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform") { Kind = UsdPrimKindEnum.Component }; + prim.Metadata["comment"] = "torque curve peaks at (n) then drops)"; + stage.AddRootPrim(prim); + + UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(stage), stage.StageName); + + UsdPrim prim2 = reparsed.Find("/P")!; + Assert.That(prim2.Kind, Is.EqualTo(UsdPrimKindEnum.Component)); + Assert.That(prim2.Metadata["comment"], Is.EqualTo("torque curve peaks at (n) then drops)")); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs b/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs new file mode 100644 index 0000000000..a581bde783 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/ConversionFixTests.cs @@ -0,0 +1,307 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Regression tests for the conversion-layer conformance fixes: + /// + /// C2 โ€” an unrecognised (opaque) structured value is rendered faithfully in USD + /// syntax rather than published as a CLR type name, and fails closed when it cannot be + /// rendered (ยง8.4). + /// H3 โ€” matrix4d and every nested-tuple fixed-size type is flattened to its + /// leaves before the arity check, so it is no longer silently dropped. + /// M โ€” an asset value is emitted with @โ€ฆ@ delimiters, not as a quoted + /// string. + /// H4 โ€” the writer emits a co-authored default value together with every connection + /// target, not just the first connection (ยง5.4). + /// + /// + [TestFixture] + public class ConversionFixTests + { + private static bool Coerce(string typeName, object? value, out Variant result) + { + UsdValueTypeMapping mapping = UsdValueTypeMap.Map(typeName, null); + uint components = UsdValueTypeMap.ComponentCount(typeName); + return UsdValueCoercion.TryCoerce(value, mapping, components, out result); + } + + // ---- C2: opaque structured values render faithfully, never a CLR type name ----- + + [Test] + public void OpaqueTuple_RendersUsdSyntax_NotClrTypeName() + { + // color4f is not in the value-type table, so it is carried opaquely. A tuple must be + // rendered as "(...)" rather than the literal "System.Object[]". + bool ok = Coerce("color4f", new object?[] { 0.1, 0.2, 0.3, 1.0 }, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out string rendered), Is.True); + Assert.That(rendered, Is.EqualTo("(0.1, 0.2, 0.3, 1.0)")); + Assert.That(rendered, Does.Not.Contain("System.")); + } + + [Test] + public void OpaqueArray_RendersUsdSyntax_NotClrTypeName() + { + // A [...] array is modelled as List; it must render as "[...]" rather than + // the literal "System.Collections.Generic.List`1[System.Object]". + bool ok = Coerce("mvtype", new List { 1L, 2L, 3L }, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out string rendered), Is.True); + Assert.That(rendered, Is.EqualTo("[1, 2, 3]")); + Assert.That(rendered, Does.Not.Contain("System.")); + } + + [Test] + public void OpaqueNestedTuple_RendersRecursively() + { + // matrix2d authored as two nested 2-tuples must render every level. + bool ok = Coerce( + "matrix2d", + new object?[] { new object?[] { 1L, 0L }, new object?[] { 0L, 1L } }, + out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out string rendered), Is.True); + Assert.That(rendered, Is.EqualTo("((1, 0), (0, 1))")); + } + + [Test] + public void OpaqueStringLeaf_IsQuotedInsideStructure() + { + bool ok = Coerce("mvtype", new List { "a", "b" }, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out string rendered), Is.True); + Assert.That(rendered, Is.EqualTo("[\"a\", \"b\"]")); + } + + [Test] + public void OpaqueValue_FailsClosed_WhenLeafCannotBeRendered() + { + // A leaf the writer cannot render faithfully must leave the value unresolved rather + // than publish a plausible-but-wrong string (fail closed). + bool ok = Coerce( + "mvtype", new object?[] { 0.1, DateTime.UtcNow }, out Variant v); + + Assert.That(ok, Is.False); + Assert.That(v.TryGetValue(out string _), Is.False); + } + + // ---- H3: matrix4d (and nested-tuple fixed types) flatten before the arity check ---- + + [Test] + public void Matrix4d_NestedTuples_AreFlattenedAndHonoured() + { + object?[] nested = + { + new object?[] { 1.0, 0.0, 0.0, 0.0 }, + new object?[] { 0.0, 1.0, 0.0, 0.0 }, + new object?[] { 0.0, 0.0, 1.0, 0.0 }, + new object?[] { 0.0, 0.0, 0.0, 1.0 }, + }; + + bool ok = Coerce("matrix4d", nested, out Variant v); + + Assert.That(ok, Is.True, "matrix4d authored as four nested 4-tuples must not be dropped"); + Assert.That(v.TryGetValue(out ArrayOf arr), Is.True); + double[]? flat = arr.ToArray(); + Assert.That(flat, Has.Length.EqualTo(16)); + Assert.That(flat![0], Is.EqualTo(1.0)); + Assert.That(flat[5], Is.EqualTo(1.0)); + Assert.That(flat[10], Is.EqualTo(1.0)); + Assert.That(flat[15], Is.EqualTo(1.0)); + Assert.That(flat[1], Is.Zero); + } + + [Test] + public void Matrix4d_AlreadyFlat_StillHonoured() + { + object?[] flatAuthored = + { + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + }; + + bool ok = Coerce("matrix4d", flatAuthored, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out ArrayOf arr), Is.True); + Assert.That(arr.ToArray(), Has.Length.EqualTo(16)); + } + + [Test] + public void Matrix4dArray_NestedTuples_AreFlattenedPerRow() + { + object?[] identity = + { + new object?[] { 1.0, 0.0, 0.0, 0.0 }, + new object?[] { 0.0, 1.0, 0.0, 0.0 }, + new object?[] { 0.0, 0.0, 1.0, 0.0 }, + new object?[] { 0.0, 0.0, 0.0, 1.0 }, + }; + var value = new List { identity, identity }; + + bool ok = Coerce("matrix4d[]", value, out Variant v); + + Assert.That(ok, Is.True, "matrix4d[] rows authored as nested tuples must not be dropped"); + Assert.That(v.TryGetValue(out MatrixOf m), Is.True); + Assert.That(m.Dimensions, Is.EqualTo(new[] { 2, 16 })); + } + + [Test] + public void Color3fArray_StaysGrouped_NotOverFlattened() + { + // The over-flatten guard: color3f[] is a sequence of 3-tuples. Flattening applies to + // the element shape only, so the outer array must keep two rows of three, not collapse + // to one flat run. + var value = new List + { + new object?[] { 1f, 2f, 3f }, + new object?[] { 4f, 5f, 6f }, + }; + + bool ok = Coerce("color3f[]", value, out Variant v); + + Assert.That(ok, Is.True); + Assert.That(v.TryGetValue(out MatrixOf m), Is.True); + Assert.That(m.Dimensions, Is.EqualTo(new[] { 2, 3 })); + } + + [Test] + public void WrongArityFixedType_StillFailsClosed_AfterFlatten() + { + // Flattening must not paper over a genuinely wrong arity: float3 with two components + // still cannot be honoured. + bool ok = Coerce("float3", new object?[] { 1f, 2f }, out Variant v); + + Assert.That(ok, Is.False); + Assert.That(v.TryGetValue(out ArrayOf _), Is.False); + } + + // ---- M: asset values are emitted with @...@ delimiters ------------------------- + + [Test] + public void AssetValue_IsEmittedWithAtDelimiters_NotQuoted() + { + var stage = new UsdStage("Assets") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add(new UsdAttribute("inputs:file", "asset") { Value = "./pump.usda" }); + stage.AddRootPrim(prim); + + string usda = UsdaWriter.Write(stage); + + Assert.That(usda, Does.Contain("@./pump.usda@")); + Assert.That(usda, Does.Not.Contain("\"./pump.usda\"")); + } + + [Test] + public void AssetArray_EmitsEachElementWithAtDelimiters() + { + var stage = new UsdStage("Assets") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add( + new UsdAttribute("inputs:files", "asset[]") + { + Value = new List { "./a.usda", "./b.usda" }, + }); + stage.AddRootPrim(prim); + + string usda = UsdaWriter.Write(stage); + + Assert.That(usda, Does.Contain("[@./a.usda@, @./b.usda@]")); + } + + [Test] + public void OpaqueCarriedValue_IsEmittedVerbatim_NotReQuoted() + { + // On export an opaque value arrives as a string on an opaque-typed attribute; the + // writer must emit it verbatim so the structured text survives the round trip. + var stage = new UsdStage("Opaque") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add(new UsdAttribute("extent", "color4f") { Value = "(0.1, 0.2, 0.3, 1.0)" }); + stage.AddRootPrim(prim); + + string usda = UsdaWriter.Write(stage); + + Assert.That(usda, Does.Contain("color4f extent = (0.1, 0.2, 0.3, 1.0)")); + Assert.That(usda, Does.Not.Contain("\"(0.1, 0.2, 0.3, 1.0)\"")); + } + + // ---- H4: writer emits every connection and the co-authored value -------------- + + [Test] + public void MultipleConnections_AreAllEmitted_WithCoAuthoredValue() + { + var stage = new UsdStage("Conn") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + var attr = new UsdAttribute("inputs:surface", "token") { Value = "fallback" }; + attr.Connections.Add("/P/A.outputs:surface"); + attr.Connections.Add("/P/B.outputs:surface"); + prim.Attributes.Add(attr); + stage.AddRootPrim(prim); + + string usda = UsdaWriter.Write(stage); + + // The co-authored default value is emitted... + Assert.That(usda, Does.Contain("token inputs:surface = \"fallback\"")); + // ...together with every connection target, not only the first. + Assert.That( + usda, + Does.Contain(".connect = [

,

]")); + } + + [Test] + public void SingleConnection_IsEmittedAsBarePathReference() + { + var stage = new UsdStage("Conn") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + var attr = new UsdAttribute("inputs:surface", "token"); + attr.Connections.Add("/P/A.outputs:surface"); + prim.Attributes.Add(attr); + stage.AddRootPrim(prim); + + string usda = UsdaWriter.Write(stage); + + Assert.That(usda, Does.Contain(".connect =

")); + Assert.That(usda, Does.Not.Contain("[

]")); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/DiscoveryInteropTests.cs b/tests/Opc.Ua.OpenUsd.Tests/DiscoveryInteropTests.cs new file mode 100644 index 0000000000..f662dd5a55 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/DiscoveryInteropTests.cs @@ -0,0 +1,154 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Discovery and Part 1 interoperability (ยง4.3, ยง10): a well-known stages folder is created + /// (or the supplied Part 1 folder reused), and live-binding targets resolve by path. + /// + [TestFixture] + public class DiscoveryInteropTests + { + // ---- EnsureStagesFolder (ยง4.3) ------------------------------------------------- + + [Test] + public void EnsureStagesFolder_CreatesStandaloneRoot_WhenNoPart1Folder() + { + (SystemContext context, ushort ns, BaseObjectState server) = + MaterializationHarness.NewContext(); + + FolderState stages = context.EnsureStagesFolder(server, ns); + + Assert.That(stages, Is.Not.Null); + Assert.That(stages.BrowseName.Name, Is.EqualTo(UsdSceneDiscovery.StagesFolderName)); + Assert.That(stages.NodeId.IsNull, Is.False); + + List roots = + MaterializationHarness.ChildrenOfType(context, server); + FolderState openUsdScene = + roots.Single(f => f.BrowseName.Name == UsdSceneDiscovery.OpenUsdSceneRootName); + + List underRoot = + MaterializationHarness.ChildrenOfType(context, openUsdScene); + Assert.That(underRoot, Has.Count.EqualTo(1)); + Assert.That(underRoot[0].BrowseName.Name, Is.EqualTo(UsdSceneDiscovery.StagesFolderName)); + } + + [Test] + public void EnsureStagesFolder_ReturnsSuppliedPart1Folder_AsIs() + { + (SystemContext context, ushort ns, BaseObjectState server) = + MaterializationHarness.NewContext(); + var part1 = new FolderState(null) + { + BrowseName = new QualifiedName(UsdSceneDiscovery.StagesFolderName, ns), + NodeId = new NodeId(9999u, ns) + }; + + FolderState result = context.EnsureStagesFolder(server, ns, part1); + + Assert.That(ReferenceEquals(result, part1), Is.True); + // The standalone root is not created when Part 1 already provides one. + List roots = + MaterializationHarness.ChildrenOfType(context, server); + Assert.That( + roots.Any(f => f.BrowseName.Name == UsdSceneDiscovery.OpenUsdSceneRootName), + Is.False); + } + + [Test] + public void EnsureStagesFolder_IsIdempotent() + { + (SystemContext context, ushort ns, BaseObjectState server) = + MaterializationHarness.NewContext(); + + FolderState first = context.EnsureStagesFolder(server, ns); + FolderState second = context.EnsureStagesFolder(server, ns); + + Assert.That(ReferenceEquals(first, second), Is.True); + + List roots = + MaterializationHarness.ChildrenOfType(context, server); + Assert.That( + roots.Count(f => f.BrowseName.Name == UsdSceneDiscovery.OpenUsdSceneRootName), + Is.EqualTo(1)); + } + + // ---- Binding target resolution (ยง10) ------------------------------------------- + + [Test] + public void TryResolveBindingTarget_ResolvesRealAttribute() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + + bool resolved = ms.Result.TryResolveBindingTarget( + "/Plant/Pumps/P101/Body", "radius", out UsdAttributeState? attribute); + + Assert.That(resolved, Is.True); + Assert.That(attribute, Is.Not.Null); + Assert.That(attribute!.UsdTypeName!.Value, Is.EqualTo("double")); + + NodeId nodeId = ms.Result.ResolveBindingTargetNodeId("/Plant/Pumps/P101/Body", "radius"); + Assert.That(nodeId.IsNull, Is.False); + Assert.That(nodeId, Is.EqualTo(attribute.NodeId)); + } + + [Test] + public void TryResolveBindingTarget_FailsCleanly_ForBogusPath() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + + bool resolved = ms.Result.TryResolveBindingTarget( + "/Nope/DoesNotExist", "bogus", out UsdAttributeState? attribute); + + Assert.That(resolved, Is.False); + Assert.That(attribute, Is.Null); + + NodeId nodeId = ms.Result.ResolveBindingTargetNodeId("/Nope/DoesNotExist", "bogus"); + Assert.That(nodeId.IsNull, Is.True); + } + + [Test] + public void TryResolveBindingTarget_FailsCleanly_ForEmptyInputs() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + + Assert.That( + ms.Result.TryResolveBindingTarget(string.Empty, "radius", out _), Is.False); + Assert.That( + ms.Result.TryResolveBindingTarget("/Plant", string.Empty, out _), Is.False); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/FakeSessionHarness.cs b/tests/Opc.Ua.OpenUsd.Tests/FakeSessionHarness.cs new file mode 100644 index 0000000000..133ecadf8a --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/FakeSessionHarness.cs @@ -0,0 +1,586 @@ +/* ======================================================================== + * 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Opc.Ua.Client; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// A tiny in-memory OPC UA address space that answers Browse and Read requests, + /// so the connector's discovery, resolution and authorisation paths can be driven + /// deterministically without a live server. + /// + internal sealed class FakeAddressSpace + { + /// + /// One reference held by the fake address space. + /// + private sealed class FakeReference + { + public NodeId Source { get; init; } = NodeId.Null; + public NodeId Target { get; init; } = NodeId.Null; + public NodeId ReferenceTypeId { get; init; } = NodeId.Null; + public QualifiedName BrowseName { get; init; } = QualifiedName.Null; + public NodeClass NodeClass { get; init; } + public NodeId TypeDefinition { get; init; } = NodeId.Null; + } + + private readonly List m_references = []; + private readonly Dictionary m_values = []; + private readonly Dictionary m_accessLevels = []; + private readonly Dictionary m_rolePermissions = []; + private readonly Dictionary m_executables = []; + private readonly Dictionary m_fileContents = []; + private readonly Dictionary m_fileMethods = []; + private readonly Dictionary m_openHandles = []; + private int m_nextIdentifier = 5000; + private int m_nextHandle; + + /// + /// The namespace table shared with the fake session. + /// + public NamespaceTable NamespaceUris { get; } = new(); + + /// + /// The namespace index the OpenUSD companion specification occupies. + /// + public ushort OpenUsdNamespaceIndex { get; } + + /// + /// Number of Browse service invocations served so far. + /// + public int BrowseCount { get; private set; } + + /// + /// Number of Read service invocations served so far. + /// + public int ReadCount { get; private set; } + + /// + /// Creates an address space whose namespace table carries the OpenUSD namespace. + /// + public FakeAddressSpace() + { + OpenUsdNamespaceIndex = (ushort)NamespaceUris.GetIndexOrAppend(OpenUsdModel.NamespaceUri); + } + + /// + /// Allocates a fresh numeric NodeId in namespace zero. + /// + public NodeId NewNodeId() + { + return new NodeId((uint)Interlocked.Increment(ref m_nextIdentifier)); + } + + /// + /// Adds an Object child reachable through the supplied hierarchical reference type. + /// + public NodeId AddObject( + NodeId parent, + string browseName, + NodeId typeDefinition = default, + NodeId referenceTypeId = default, + ushort browseNameNamespace = 0, + NodeId nodeId = default) + { + return Add(parent, browseName, NodeClass.Object, typeDefinition, + referenceTypeId.IsNull ? ReferenceTypeIds.Organizes : referenceTypeId, + browseNameNamespace, nodeId); + } + + /// + /// Adds a Variable child carrying the supplied value. + /// + public NodeId AddVariable( + NodeId parent, + string browseName, + Variant value, + NodeId referenceTypeId = default, + ushort browseNameNamespace = 0, + NodeId nodeId = default) + { + NodeId id = Add(parent, browseName, NodeClass.Variable, NodeId.Null, + referenceTypeId.IsNull ? ReferenceTypeIds.HasComponent : referenceTypeId, + browseNameNamespace, nodeId); + m_values[id] = new DataValue(value); + return id; + } + + /// + /// Adds a Method child. + /// + public NodeId AddMethod(NodeId parent, string browseName) + { + return Add(parent, browseName, NodeClass.Method, NodeId.Null, + ReferenceTypeIds.HasComponent, 0, default); + } + + /// + /// Adds a non-hierarchical reference (used for HasDictionaryEntry semantics). + /// + public void AddReference(NodeId source, NodeId referenceTypeId, NodeId target, string browseName) + { + m_references.Add(new FakeReference + { + Source = source, + Target = target, + ReferenceTypeId = referenceTypeId, + BrowseName = new QualifiedName(browseName), + NodeClass = NodeClass.Object, + TypeDefinition = NodeId.Null + }); + } + + /// + /// Adds the Part 5 FileType Open/Read/Close methods that stream the supplied + /// content, so the connector can fetch a served layer through the fake session. + /// + public void AddPart5File(NodeId fileNodeId, byte[] content) + { + m_fileContents[fileNodeId] = content; + m_fileMethods[Add(fileNodeId, "Open", NodeClass.Method, NodeId.Null, + ReferenceTypeIds.HasComponent, 0, default)] = (fileNodeId, "Open"); + m_fileMethods[Add(fileNodeId, "Read", NodeClass.Method, NodeId.Null, + ReferenceTypeIds.HasComponent, 0, default)] = (fileNodeId, "Read"); + m_fileMethods[Add(fileNodeId, "Close", NodeClass.Method, NodeId.Null, + ReferenceTypeIds.HasComponent, 0, default)] = (fileNodeId, "Close"); + } + + /// + /// When set, the fake Open method returns these output arguments instead of a + /// file handle. + /// + public Variant[]? OpenResultOverride { get; set; } + + /// + /// Number of Close invocations served so far. + /// + public int CloseCount { get; private set; } + + /// + /// Serves a Call request for the Part 5 file methods registered with + /// . + /// + public CallResponse Call(ArrayOf requests) + { + var results = new List(); + for (int i = 0; i < requests.Count; i++) + { + CallMethodRequest request = requests[i]; + results.Add(CallOne(request)); + } + return new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = results.ToArrayOf(), + DiagnosticInfos = [] + }; + } + + private CallMethodResult CallOne(CallMethodRequest request) + { + if (!m_fileMethods.TryGetValue(request.MethodId, + out (NodeId File, string Name) method)) + { + return new CallMethodResult { StatusCode = StatusCodes.BadMethodInvalid }; + } + switch (method.Name) + { + case "Open": + if (OpenResultOverride != null) + { + return new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = OpenResultOverride.ToArrayOf() + }; + } + uint handle = (uint)Interlocked.Increment(ref m_nextHandle); + m_openHandles[handle] = (method.File, 0); + return new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = [new Variant(handle)] + }; + case "Read": + return Read(request); + default: + CloseCount++; + return new CallMethodResult { StatusCode = StatusCodes.Good }; + } + } + + private CallMethodResult Read(CallMethodRequest request) + { + if (!request.InputArguments[0].TryGetValue(out uint handle) || + !m_openHandles.TryGetValue(handle, out (NodeId File, int Offset) state)) + { + return new CallMethodResult { StatusCode = StatusCodes.BadInvalidArgument }; + } + if (!request.InputArguments[1].TryGetValue(out int chunkSize)) + { + chunkSize = 8192; + } + byte[] content = m_fileContents[state.File]; + int remaining = content.Length - state.Offset; + int take = remaining < chunkSize ? remaining : chunkSize; + if (take < 0) + { + take = 0; + } + var chunk = new byte[take]; + Array.Copy(content, state.Offset, chunk, 0, take); + m_openHandles[handle] = (state.File, state.Offset + take); + return new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = [new Variant(new ByteString(chunk))] + }; + } + + /// + /// Overrides the value returned for the Value attribute of a node. + /// + public void SetValue(NodeId nodeId, DataValue value) + { + m_values[nodeId] = value; + } + + /// + /// Sets the UserAccessLevel attribute reported for a node. + /// + public void SetUserAccessLevel(NodeId nodeId, DataValue value) + { + m_accessLevels[nodeId] = value; + } + + /// + /// Sets the UserRolePermissions attribute reported for a node. + /// + public void SetUserRolePermissions(NodeId nodeId, DataValue value) + { + m_rolePermissions[nodeId] = value; + } + + /// + /// Sets the UserExecutable attribute reported for a method node. + /// + public void SetUserExecutable(NodeId nodeId, DataValue value) + { + m_executables[nodeId] = value; + } + + /// + /// Serves a Browse request from the in-memory graph. + /// + public BrowseResponse Browse(ArrayOf nodesToBrowse) + { + BrowseCount++; + var results = new List(); + for (int i = 0; i < nodesToBrowse.Count; i++) + { + BrowseDescription d = nodesToBrowse[i]; + var refs = new List(); + foreach (FakeReference r in m_references) + { + bool forward = r.Source == d.NodeId; + bool inverse = r.Target == d.NodeId; + if (!forward && !inverse) + { + continue; + } + if (d.BrowseDirection == BrowseDirection.Forward && !forward) + { + continue; + } + if (d.BrowseDirection == BrowseDirection.Inverse && !inverse) + { + continue; + } + if (!Matches(r.ReferenceTypeId, d.ReferenceTypeId, d.IncludeSubtypes)) + { + continue; + } + refs.Add(new ReferenceDescription + { + NodeId = new ExpandedNodeId(forward ? r.Target : r.Source), + BrowseName = r.BrowseName, + DisplayName = new LocalizedText(r.BrowseName.Name ?? string.Empty), + NodeClass = r.NodeClass, + ReferenceTypeId = r.ReferenceTypeId, + IsForward = forward, + TypeDefinition = new ExpandedNodeId(r.TypeDefinition) + }); + } + results.Add(new BrowseResult + { + StatusCode = StatusCodes.Good, + References = refs.ToArrayOf() + }); + } + return new BrowseResponse + { + ResponseHeader = new ResponseHeader(), + Results = results.ToArrayOf(), + DiagnosticInfos = [] + }; + } + + /// + /// Attribute ids for which the fake Read service raises a service fault. + /// + public HashSet FaultingAttributes { get; } = []; + + /// + /// Serves a Read request from the in-memory graph. + /// + public ReadResponse Read(ArrayOf nodesToRead) + { + ReadCount++; + var results = new List(); + for (int i = 0; i < nodesToRead.Count; i++) + { + ReadValueId r = nodesToRead[i]; + if (FaultingAttributes.Contains(r.AttributeId)) + { + throw new ServiceResultException(StatusCodes.BadNotReadable); + } + results.Add(ReadAttribute(r.NodeId, r.AttributeId)); + } + return new ReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = results.ToArrayOf(), + DiagnosticInfos = [] + }; + } + + private DataValue ReadAttribute(NodeId nodeId, uint attributeId) + { + switch (attributeId) + { + case Attributes.Value: + return m_values.TryGetValue(nodeId, out DataValue v) + ? v + : DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown); + case Attributes.NodeClass: + NodeClass nodeClass = NodeClassOf(nodeId); + return nodeClass == NodeClass.Unspecified + ? DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown) + : new DataValue(new Variant((int)nodeClass)); + case Attributes.UserAccessLevel: + return m_accessLevels.TryGetValue(nodeId, out DataValue a) + ? a + : DataValue.FromStatusCode(StatusCodes.BadAttributeIdInvalid); + case Attributes.UserRolePermissions: + return m_rolePermissions.TryGetValue(nodeId, out DataValue p) + ? p + : DataValue.FromStatusCode(StatusCodes.BadAttributeIdInvalid); + case Attributes.UserExecutable: + return m_executables.TryGetValue(nodeId, out DataValue e) + ? e + : DataValue.FromStatusCode(StatusCodes.BadAttributeIdInvalid); + default: + return DataValue.FromStatusCode(StatusCodes.BadAttributeIdInvalid); + } + } + + private NodeClass NodeClassOf(NodeId nodeId) + { + foreach (FakeReference r in m_references) + { + if (r.Target == nodeId) + { + return r.NodeClass; + } + } + return NodeClass.Unspecified; + } + + private NodeId Add( + NodeId parent, + string browseName, + NodeClass nodeClass, + NodeId typeDefinition, + NodeId referenceTypeId, + ushort browseNameNamespace, + NodeId nodeId) + { + NodeId id = nodeId.IsNull ? NewNodeId() : nodeId; + m_references.Add(new FakeReference + { + Source = parent, + Target = id, + ReferenceTypeId = referenceTypeId, + BrowseName = new QualifiedName(browseName, browseNameNamespace), + NodeClass = nodeClass, + TypeDefinition = typeDefinition + }); + return id; + } + + private static readonly Dictionary s_referenceTypeParents = new() + { + { ReferenceTypeIds.HierarchicalReferences, ReferenceTypeIds.References }, + { ReferenceTypeIds.NonHierarchicalReferences, ReferenceTypeIds.References }, + { ReferenceTypeIds.HasChild, ReferenceTypeIds.HierarchicalReferences }, + { ReferenceTypeIds.Organizes, ReferenceTypeIds.HierarchicalReferences }, + { ReferenceTypeIds.HasEventSource, ReferenceTypeIds.HierarchicalReferences }, + { ReferenceTypeIds.Aggregates, ReferenceTypeIds.HasChild }, + { ReferenceTypeIds.HasSubtype, ReferenceTypeIds.HasChild }, + { ReferenceTypeIds.HasComponent, ReferenceTypeIds.Aggregates }, + { ReferenceTypeIds.HasProperty, ReferenceTypeIds.Aggregates }, + { ReferenceTypeIds.HasOrderedComponent, ReferenceTypeIds.HasComponent }, + { ReferenceTypeIds.HasNotifier, ReferenceTypeIds.HasEventSource }, + { ReferenceTypeIds.HasDictionaryEntry, ReferenceTypeIds.NonHierarchicalReferences }, + { ReferenceTypeIds.HasTypeDefinition, ReferenceTypeIds.NonHierarchicalReferences } + }; + + private static bool Matches(NodeId actual, NodeId requested, bool includeSubtypes) + { + if (requested.IsNull || requested == ReferenceTypeIds.References) + { + return true; + } + if (actual == requested) + { + return true; + } + if (!includeSubtypes) + { + return false; + } + NodeId current = actual; + while (s_referenceTypeParents.TryGetValue(current, out NodeId parent)) + { + if (parent == requested) + { + return true; + } + current = parent; + } + return false; + } + } + + /// + /// Builds a wired to a , + /// including the properties the client's Browser requires. + /// + internal static class FakeSession + { + /// + /// Creates a session mock that serves Browse and Read from the address space. + /// + public static Mock Create(FakeAddressSpace space) + { + var telemetry = new Mock(); + telemetry.SetupGet(t => t.LoggerFactory).Returns(NullLoggerFactory.Instance); + + var messageContext = new Mock(); + messageContext.SetupGet(c => c.Telemetry).Returns(telemetry.Object); + messageContext.SetupGet(c => c.NamespaceUris).Returns(space.NamespaceUris); + + var session = new Mock(); + session.SetupGet(s => s.NamespaceUris).Returns(space.NamespaceUris); + session.SetupGet(s => s.MessageContext).Returns(messageContext.Object); + session.SetupGet(s => s.OperationLimits).Returns(new OperationLimits + { + MaxNodesPerBrowse = 100, + MaxNodesPerRead = 100 + }); + session.SetupGet(s => s.ServerCapabilities).Returns(new ServerCapabilities + { + MaxBrowseContinuationPoints = 0 + }); + session.SetupGet(s => s.ContinuationPointPolicy).Returns(ContinuationPointPolicy.Default); + + session + .Setup(s => s.BrowseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((RequestHeader _, ViewDescription _, uint _, + ArrayOf nodes, CancellationToken _) => + new ValueTask(space.Browse(nodes))); + + session + .Setup(s => s.ReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((RequestHeader _, double _, TimestampsToReturn _, + ArrayOf nodes, CancellationToken _) => + new ValueTask(space.Read(nodes))); + + session + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((RequestHeader _, ArrayOf requests, CancellationToken _) => + new ValueTask(space.Call(requests))); + + return session; + } + + /// + /// Convenience helper building a TranslateBrowsePathsToNodeIds response. + /// + public static TranslateBrowsePathsToNodeIdsResponse TranslateResponse( + StatusCode statusCode, + params BrowsePathTarget[] targets) + { + return new TranslateBrowsePathsToNodeIdsResponse + { + ResponseHeader = new ResponseHeader(), + Results = + [ + new BrowsePathResult + { + StatusCode = statusCode, + Targets = targets.ToArrayOf() + } + ], + DiagnosticInfos = [] + }; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceAnchorCoercionTests.cs b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceAnchorCoercionTests.cs new file mode 100644 index 0000000000..7463f46dc1 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceAnchorCoercionTests.cs @@ -0,0 +1,143 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Unit tests for the numeric coercion the portable georeference dual-authoring applies to + /// a Cesium anchor (ยง5.8). A vendor stage may author latitude, longitude and height in any + /// numeric spelling โ€” or as text โ€” and every one of those must yield the same portable + /// anchor, while a value of a kind that carries no number must fail closed and publish no + /// anchor at all. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class GeoreferenceAnchorCoercionTests + { + private const double Latitude = 47.6062; + private const double Longitude = -122.3321; + + [Test] + public void AnAnchorAuthoredInMixedNumericTypesIsCoercedToDouble() + { + UsdPrim world = TypedGeoreference( + latitude: 47.0f, longitude: -122L, height: 56); + + List portable = PortableGeoreference(world); + + Assert.That(portable, Has.Count.EqualTo(1)); + Assert.That(portable[0].Latitude!.Value, Is.EqualTo(47.0).Within(1e-5)); + Assert.That(portable[0].Longitude!.Value, Is.EqualTo(-122.0).Within(1e-12)); + Assert.That(portable[0].Height!.Value, Is.EqualTo(56.0).Within(1e-12)); + } + + [Test] + public void AnAnchorAuthoredAsInvariantTextIsParsed() + { + UsdPrim world = TypedGeoreference( + latitude: "47.6062", longitude: "-122.3321", height: "56.0"); + + List portable = PortableGeoreference(world); + + Assert.That(portable, Has.Count.EqualTo(1)); + Assert.That(portable[0].Latitude!.Value, Is.EqualTo(Latitude).Within(1e-12)); + Assert.That(portable[0].Longitude!.Value, Is.EqualTo(Longitude).Within(1e-12)); + } + + [Test] + public void AnAnchorWithAnUnparsableComponentPublishesNoPortableAnchor() + { + UsdPrim world = TypedGeoreference( + latitude: "north", longitude: Longitude, height: 56.0); + + Assert.That(PortableGeoreference(world), Is.Empty); + } + + [Test] + public void AGlobeAnchorMissingItsHeightPublishesNoPortableAnchor() + { + var anchor = new UsdPrim("Anchor", "CesiumGlobeAnchorAPI"); + anchor.Attributes.Add( + new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + anchor.Attributes.Add( + new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); + + var stage = new UsdStage("Test"); + stage.AddRootPrim(anchor); + MaterializedScene scene = MaterializationHarness.Materialize(stage); + + Assert.That(scene.AppliedSchemas("/Anchor"), Is.Empty); + } + + [Test] + public void AGlobeAnchorWithACompleteAnchorPublishesThePortableAnchor() + { + var anchor = new UsdPrim("Anchor", "CesiumGlobeAnchorAPI"); + anchor.Attributes.Add( + new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + anchor.Attributes.Add( + new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); + anchor.Attributes.Add( + new UsdAttribute("cesium:anchor:height", "double") { Value = 56.0 }); + + var stage = new UsdStage("Test"); + stage.AddRootPrim(anchor); + MaterializedScene scene = MaterializationHarness.Materialize(stage); + + List portable = + scene.AppliedSchemas("/Anchor"); + Assert.That(portable, Has.Count.EqualTo(1)); + Assert.That(portable[0].Height!.Value, Is.EqualTo(56.0).Within(1e-12)); + } + + private static UsdPrim TypedGeoreference(object latitude, object longitude, object height) + { + var world = new UsdPrim("World", "CesiumGeoreferencePrim"); + world.Attributes.Add( + new UsdAttribute("cesium:anchor:latitude", "double") { Value = latitude }); + world.Attributes.Add( + new UsdAttribute("cesium:anchor:longitude", "double") { Value = longitude }); + world.Attributes.Add( + new UsdAttribute("cesium:anchor:height", "double") { Value = height }); + return world; + } + + private static List PortableGeoreference(UsdPrim world) + { + var stage = new UsdStage("Test"); + stage.AddRootPrim(world); + return MaterializationHarness.Materialize(stage) + .AppliedSchemas("/World"); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTests.cs b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTests.cs new file mode 100644 index 0000000000..8da7fd832b --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTests.cs @@ -0,0 +1,163 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Georeferencing tests (ยง5.8, Annex B): a recognised Cesium anchor is dual-authored into + /// the portable well-known types, fails closed on a partial anchor, and can be suppressed. + /// + [TestFixture] + public class GeoreferenceTests + { + private const double Latitude = 47.6062; + private const double Longitude = -122.3321; + private const double Height = 56.0; + + private const double AnchorLatitude = 47.6205; + private const double AnchorLongitude = -122.3493; + private const double AnchorHeight = 12.5; + + // ---- Dual authoring (Annex B.3) ------------------------------------------------ + + [Test] + public void GeoreferencePrim_DualAuthorsPortableAnchor() + { + MaterializedScene ms = MaterializationHarness.Materialize(AnnexBScene()); + + List portable = ms.AppliedSchemas("/Site"); + Assert.That(portable, Has.Count.EqualTo(1)); + + UsdGeoreferenceApiState node = portable[0]; + Assert.That( + node.BrowseName.Name, + Is.EqualTo(Opc.Ua.OpenUsdScene.BrowseNames.UsdGeoreferenceApiType)); + Assert.That(node.Latitude!.Value, Is.EqualTo(Latitude)); + Assert.That(node.Longitude!.Value, Is.EqualTo(Longitude)); + Assert.That(node.Height!.Value, Is.EqualTo(Height)); + Assert.That(node.EpsgCode!.Value, Is.EqualTo((uint)4326)); + Assert.That(node.TangentPlane!.Value, Is.EqualTo("ENU")); + + // The vendor schema is still materialized alongside the portable one. + List all = ms.AppliedSchemas("/Site"); + Assert.That(all.Any(s => s.SchemaName!.Value == "CesiumGeoreferencePrim"), Is.True); + } + + [Test] + public void GlobeAnchorApi_DualAuthorsPortableAnchor_OnChild() + { + MaterializedScene ms = MaterializationHarness.Materialize(AnnexBScene()); + + List portable = + ms.AppliedSchemas("/Site/Anchor"); + Assert.That(portable, Has.Count.EqualTo(1)); + + UsdGlobeAnchorApiState node = portable[0]; + Assert.That( + node.BrowseName.Name, + Is.EqualTo(Opc.Ua.OpenUsdScene.BrowseNames.UsdGlobeAnchorApiType)); + Assert.That(node.Latitude!.Value, Is.EqualTo(AnchorLatitude)); + Assert.That(node.Longitude!.Value, Is.EqualTo(AnchorLongitude)); + Assert.That(node.Height!.Value, Is.EqualTo(AnchorHeight)); + + List all = ms.AppliedSchemas("/Site/Anchor"); + Assert.That(all.Any(s => s.SchemaName!.Value == "CesiumGlobeAnchorAPI"), Is.True); + } + + // ---- Fail closed on a partial anchor ------------------------------------------- + + [Test] + public void PartialAnchor_MissingLongitude_PublishesNoPortableAnchor() + { + var stage = new UsdStage("Geo") { DefaultPrim = "Site" }; + var site = new UsdPrim("Site", "Xform"); + site.ApiSchemas.Add(new UsdApiSchema("CesiumGeoreferencePrim")); + // Latitude only โ€” a partial anchor would place the prim at a wrong position. + site.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + site.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + stage.AddRootPrim(site); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + Assert.That(ms.AppliedSchemas("/Site"), Is.Empty); + // The vendor schema is not suppressed โ€” only the portable dual-author is withheld. + List all = ms.AppliedSchemas("/Site"); + Assert.That(all.Any(s => s.SchemaName!.Value == "CesiumGeoreferencePrim"), Is.True); + } + + // ---- Suppression by option ----------------------------------------------------- + + [Test] + public void DualAuthorDisabled_PublishesNoPortableAnchor_ButKeepsVendorSchema() + { + MaterializedScene ms = MaterializationHarness.Materialize( + AnnexBScene(), + new UsdMaterializationOptions { DualAuthorPortableGeoreference = false }); + + Assert.That(ms.AppliedSchemas("/Site"), Is.Empty); + Assert.That(ms.AppliedSchemas("/Site/Anchor"), Is.Empty); + + List all = ms.AppliedSchemas("/Site"); + Assert.That(all.Any(s => s.SchemaName!.Value == "CesiumGeoreferencePrim"), Is.True); + } + + // ---- helpers ------------------------------------------------------------------- + + private static UsdStage AnnexBScene() + { + var stage = new UsdStage("Geo") { DefaultPrim = "Site", UpAxis = "Z", MetersPerUnit = 1.0 }; + + var site = new UsdPrim("Site", "Xform"); + site.ApiSchemas.Add(new UsdApiSchema("CesiumGeoreferencePrim")); + site.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + site.Attributes.Add( + new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); + site.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + + var anchor = new UsdPrim("Anchor", "Xform"); + anchor.ApiSchemas.Add(new UsdApiSchema("CesiumGlobeAnchorAPI")); + anchor.Attributes.Add( + new UsdAttribute("cesium:anchor:latitude", "double") { Value = AnchorLatitude }); + anchor.Attributes.Add( + new UsdAttribute("cesium:anchor:longitude", "double") { Value = AnchorLongitude }); + anchor.Attributes.Add( + new UsdAttribute("cesium:anchor:height", "double") { Value = AnchorHeight }); + + site.AddChild(anchor); + stage.AddRootPrim(site); + return stage; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTypedPrimTests.cs b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTypedPrimTests.cs new file mode 100644 index 0000000000..4e53d548e4 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/GeoreferenceTypedPrimTests.cs @@ -0,0 +1,167 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Georeferencing on a typed Cesium prim (ยง5.8, Annex B.1): a real Cesium stage + /// authors the anchor as def CesiumGeoreferencePrim "World" { โ€ฆ } โ€” a prim whose + /// TypeName carries the schema rather than an applied apiSchemas entry โ€” and + /// the portable UsdGeoreferenceApiType dual-authoring must fire for that spelling too, + /// so a generic client always finds a vendor-neutral anchor. This complements + /// , which exercises the applied-schema spelling. + /// + [TestFixture] + public class GeoreferenceTypedPrimTests + { + private const double Latitude = 47.6062; + private const double Longitude = -122.3321; + private const double Height = 56.0; + + // ---- Typed georeference prim (Annex B.1 spelling) ------------------------------ + + [Test] + public void TypedGeoreferencePrim_DualAuthorsPortableAnchor() + { + MaterializedScene ms = MaterializationHarness.Materialize(TypedGeoreferenceScene()); + + List portable = + ms.AppliedSchemas("/World"); + Assert.That(portable, Has.Count.EqualTo(1)); + + UsdGeoreferenceApiState node = portable[0]; + Assert.That( + node.BrowseName.Name, + Is.EqualTo(Opc.Ua.OpenUsdScene.BrowseNames.UsdGeoreferenceApiType)); + Assert.That(node.Latitude!.Value, Is.EqualTo(Latitude)); + Assert.That(node.Longitude!.Value, Is.EqualTo(Longitude)); + Assert.That(node.Height!.Value, Is.EqualTo(Height)); + Assert.That(node.EpsgCode!.Value, Is.EqualTo((uint)4326)); + Assert.That(node.TangentPlane!.Value, Is.EqualTo("ENU")); + } + + [Test] + public void TypedGeoreferencePrim_KeepsTokenAndFallsBackToUsdPrimType() + { + // The schema token lives in TypeName, and because CesiumGeoreferencePrim is not one + // of the built-in typed schemas the prim degrades to a concrete UsdPrimType (ยง8.4) + // rather than being retyped to anything abstract. + MaterializedScene ms = MaterializationHarness.Materialize(TypedGeoreferenceScene()); + + UsdPrimState world = ms.Prim("/World"); + Assert.That(world.TypeName!.Value, Is.EqualTo("CesiumGeoreferencePrim")); + Assert.That( + world.TypeDefinitionId, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.ObjectTypes.UsdPrimType, ms.Namespace))); + // A typed georeference prim declares no applied apiSchemas, so the only AddIn in the + // folder is the portable anchor the materializer dual-authored โ€” the vendor schema is + // *not* invented as a phantom applied schema. + List all = ms.AppliedSchemas("/World"); + Assert.That(all, Has.Count.EqualTo(1)); + Assert.That(all.Any(s => s.SchemaName!.Value == "CesiumGeoreferencePrim"), Is.False); + Assert.That( + all[0].SchemaName!.Value, + Is.EqualTo(Opc.Ua.OpenUsdScene.BrowseNames.UsdGeoreferenceApiType)); + } + + [Test] + public void TypedGlobeAnchorPrim_DualAuthorsPortableAnchor() + { + var stage = new UsdStage("Geo") { DefaultPrim = "Anchor" }; + var anchor = new UsdPrim("Anchor", "CesiumGlobeAnchorAPI"); + anchor.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + anchor.Attributes.Add(new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); + anchor.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + stage.AddRootPrim(anchor); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + List portable = + ms.AppliedSchemas("/Anchor"); + Assert.That(portable, Has.Count.EqualTo(1)); + Assert.That(portable[0].Latitude!.Value, Is.EqualTo(Latitude)); + Assert.That(portable[0].Longitude!.Value, Is.EqualTo(Longitude)); + Assert.That(portable[0].Height!.Value, Is.EqualTo(Height)); + } + + // ---- Fail closed on a typed prim with a partial anchor ------------------------- + + [Test] + public void TypedGeoreferencePrim_MissingLongitude_PublishesNoPortableAnchor() + { + var stage = new UsdStage("Geo") { DefaultPrim = "World" }; + var world = new UsdPrim("World", "CesiumGeoreferencePrim"); + // Longitude omitted โ€” a partial anchor would place the prim at a wrong position, so + // the portable anchor is withheld entirely (fail closed). + world.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + world.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + stage.AddRootPrim(world); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + Assert.That(ms.AppliedSchemas("/World"), Is.Empty); + // The typed prim itself still materialises and keeps its token. + Assert.That(ms.Prim("/World").TypeName!.Value, Is.EqualTo("CesiumGeoreferencePrim")); + } + + // ---- Suppression by option still honoured for the typed spelling --------------- + + [Test] + public void TypedGeoreferencePrim_DualAuthorDisabled_PublishesNoPortableAnchor() + { + MaterializedScene ms = MaterializationHarness.Materialize( + TypedGeoreferenceScene(), + new UsdMaterializationOptions { DualAuthorPortableGeoreference = false }); + + Assert.That(ms.AppliedSchemas("/World"), Is.Empty); + // With no applied schema and dual-authoring off, the georeference prim needs no + // AppliedSchemas folder at all. + Assert.That(ms.Prim("/World").AppliedSchemas, Is.Null); + } + + // ---- helpers ------------------------------------------------------------------- + + private static UsdStage TypedGeoreferenceScene() + { + var stage = new UsdStage("Geo") { DefaultPrim = "World", UpAxis = "Z", MetersPerUnit = 1.0 }; + var world = new UsdPrim("World", "CesiumGeoreferencePrim"); + world.Attributes.Add(new UsdAttribute("cesium:anchor:latitude", "double") { Value = Latitude }); + world.Attributes.Add(new UsdAttribute("cesium:anchor:longitude", "double") { Value = Longitude }); + world.Attributes.Add(new UsdAttribute("cesium:anchor:height", "double") { Value = Height }); + stage.AddRootPrim(world); + return stage; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/MaterializationHarness.cs b/tests/Opc.Ua.OpenUsd.Tests/MaterializationHarness.cs new file mode 100644 index 0000000000..908ee4daf4 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/MaterializationHarness.cs @@ -0,0 +1,159 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// A NodeIdFactory that hands each newly materialized node a fresh, unique numeric + /// identifier in a dedicated instance namespace, mirroring what a real NodeManager does + /// while a subtree is being wired up. Nodes that already carry an identifier keep it. + /// + internal sealed class SequentialNodeIdFactory : INodeIdFactory + { + private readonly ushort m_namespaceIndex; + private uint m_next; + + public SequentialNodeIdFactory(ushort namespaceIndex) + { + m_namespaceIndex = namespaceIndex; + } + + public NodeId New(ISystemContext context, NodeState node) + { + return node.NodeId.IsNull ? new NodeId(++m_next, m_namespaceIndex) : node.NodeId; + } + } + + /// + /// The result of materializing a scene in a test: the context it was materialized in, the + /// companion namespace index, the root the stage hangs under and the materialization + /// result itself. + /// + internal sealed class MaterializedScene + { + public MaterializedScene( + SystemContext context, + ushort ns, + BaseObjectState root, + UsdMaterializationResult result) + { + Context = context; + Namespace = ns; + Root = root; + Result = result; + } + + public SystemContext Context { get; } + + public ushort Namespace { get; } + + public BaseObjectState Root { get; } + + public UsdMaterializationResult Result { get; } + + public UsdStageState Stage => Result.Stage; + } + + /// + /// Builds an in-process with a working NodeIdFactory and a + /// namespace table that already carries the OpenUSD Scene companion URI, so the tests can + /// materialize a stage and assert directly on the resulting tree + /// without standing up a real opc.tcp server. + /// + internal static class MaterializationHarness + { + /// + /// A private namespace the instance NodeIds are minted in. + /// + public const string InstanceNamespaceUri = "urn:opcfoundation:openusd:tests:instances"; + + /// + /// Creates a fresh context, the companion namespace index and a root Object to attach + /// a stage under. + /// + public static (SystemContext Context, ushort Namespace, BaseObjectState Root) NewContext() + { + var namespaces = new NamespaceTable(); + var ns = (ushort)namespaces.GetIndexOrAppend(Namespaces.OpenUSDScene); + var instanceNs = (ushort)namespaces.GetIndexOrAppend(InstanceNamespaceUri); + + var context = new SystemContext(null!) + { + NamespaceUris = namespaces, + ServerUris = new StringTable(), + NodeIdFactory = new SequentialNodeIdFactory(instanceNs) + }; + + var root = new BaseObjectState(null) + { + NodeId = new NodeId("TestStages", instanceNs), + BrowseName = new QualifiedName("TestStages", instanceNs), + DisplayName = new LocalizedText("TestStages"), + TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType + }; + return (context, ns, root); + } + + /// + /// Materializes under a fresh root and returns everything a + /// test needs to assert on. + /// + public static MaterializedScene Materialize( + UsdStage stage, UsdMaterializationOptions? options = null) + { + (SystemContext context, ushort ns, BaseObjectState root) = NewContext(); + UsdMaterializationResult result = context.MaterializeUsdStage(root, stage, ns, options); + return new MaterializedScene(context, ns, root, result); + } + + /// + /// Returns the materialized children of that are of type + /// , in address-space order. + /// + public static List ChildrenOfType(ISystemContext context, NodeState parent) + where T : NodeState + { + var all = new List(); + parent.GetChildren(context, all); + var typed = new List(); + foreach (BaseInstanceState child in all) + { + if (child is T match) + { + typed.Add(match); + } + } + return typed; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/MaterializerFallbackTests.cs b/tests/Opc.Ua.OpenUsd.Tests/MaterializerFallbackTests.cs new file mode 100644 index 0000000000..e06ad30ae0 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/MaterializerFallbackTests.cs @@ -0,0 +1,243 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Unknown-type fallback (ยง8.4) and failure-mode tests: nothing unrecognised is dropped, + /// and a value that cannot be honoured fails closed rather than being coerced to a wrong one. + /// + [TestFixture] + public class MaterializerFallbackTests + { + // ---- Unknown typed prim (ยง8.4) ------------------------------------------------- + + [Test] + public void UnknownTypedPrim_StaysUsdPrimType_AndKeepsToken() + { + var stage = new UsdStage("Vendor") { DefaultPrim = "Thing" }; + stage.AddRootPrim(new UsdPrim("Thing", "MyVendorThing")); + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + UsdPrimState node = ms.Prim("/Thing"); + Assert.That( + node.TypeDefinitionId, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.ObjectTypes.UsdPrimType, ms.Namespace))); + Assert.That(node.TypeName!.Value, Is.EqualTo("MyVendorThing")); + } + + [Test] + public void UnknownTypedPrim_HoldsOnRealAsset_DistantLight() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Cell.usda")); + + UsdPrimState keyLight = ms.Prim("/Cell/KeyLight"); + Assert.That( + keyLight.TypeDefinitionId, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.ObjectTypes.UsdPrimType, ms.Namespace))); + Assert.That(keyLight.TypeName!.Value, Is.EqualTo("DistantLight")); + + // Its float attribute still maps onto a concrete DataType. + UsdAttributeState intensity = ms.Attr("/Cell/KeyLight.intensity"); + Assert.That(intensity.DataType, Is.EqualTo(new NodeId(Opc.Ua.DataTypes.Float))); + Assert.That(intensity.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.Scalar)); + Assert.That(intensity.UsdTypeName!.Value, Is.EqualTo("float")); + } + + // ---- Unknown API schema (ยง8.4) ------------------------------------------------- + + [Test] + public void UnknownApiSchema_DegradesToGenericAddIn_KeepingSchemaName() + { + var stage = new UsdStage("Vendor") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.ApiSchemas.Add(new UsdApiSchema("MyVendorWidgetAPI")); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + List schemas = ms.AppliedSchemas("/P"); + Assert.That(schemas, Has.Count.EqualTo(1)); + + UsdApiSchemaState node = schemas[0]; + // The generic base type, not a specialization โ€” the schema is unrecognised. + Assert.That(node, Is.TypeOf()); + Assert.That(node.SchemaName!.Value, Is.EqualTo("MyVendorWidgetAPI")); + } + + [Test] + public void UnknownApiSchema_HoldsOnRealAsset_MaterialBindingApi() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + List schemas = + ms.AppliedSchemas("/Plant/Pumps/P101/StatusLight/Bulb"); + + Assert.That(schemas, Has.Count.EqualTo(1)); + Assert.That(schemas[0], Is.TypeOf()); + Assert.That(schemas[0].SchemaName!.Value, Is.EqualTo("MaterialBindingAPI")); + } + + // ---- Unknown value type (ยง8.4) ------------------------------------------------- + + [Test] + public void UnknownValueType_IsOpaque_ButKeepsUsdTypeName() + { + var stage = new UsdStage("Vendor") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add(new UsdAttribute("vendor:blob", "mvtype")); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdAttributeState a = ms.Attr("/P.vendor:blob"); + + Assert.That(a.DataType, Is.EqualTo(new NodeId(Opc.Ua.DataTypes.BaseDataType))); + Assert.That(a.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.Scalar)); + // The exact SdfValueTypeName is still recorded, so the mapping stays reversible. + Assert.That(a.UsdTypeName!.Value, Is.EqualTo("mvtype")); + } + + // ---- Fixed-size math type with the wrong arity fails closed -------------------- + + [Test] + public void WrongArityFixedMathType_LeavesValueUnset_ButKeepsType() + { + var stage = new UsdStage("Arity") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + // float3 declares three components; only two are authored โ€” it cannot be honoured. + prim.Attributes.Add(new UsdAttribute("badVec", "float3") { Value = new object[] { 1f, 2f } }); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdAttributeState a = ms.Attr("/P.badVec"); + + // Fail closed: no value rather than a plausible-but-wrong one. + Assert.That(a.BoxedValue(), Is.Null); + // The declared type metadata is still materialized. + Assert.That(a.DataType, Is.EqualTo(new NodeId(Opc.Ua.DataTypes.Float))); + Assert.That(a.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.OneDimension)); + Assert.That(a.Dims(), Is.EqualTo(new uint[] { 3 })); + } + + [Test] + public void CorrectArityFixedMathType_SetsValue() + { + var stage = new UsdStage("Arity") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add( + new UsdAttribute("goodVec", "float3") { Value = new object[] { 1f, 2f, 3f } }); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + Assert.That(ms.Attr("/P.goodVec").BoxedValue(), Is.Not.Null); + } + + // ---- Empty / minimal scenes ---------------------------------------------------- + + [Test] + public void EmptyStage_Materializes_WithNoPrimsOrAttributes() + { + MaterializedScene ms = MaterializationHarness.Materialize(new UsdStage("Empty")); + + Assert.That(ms.Stage.NodeId.IsNull, Is.False); + Assert.That(ms.Result.PrimsByPath, Is.Empty); + Assert.That(ms.Result.AttributesByPath, Is.Empty); + + var rootChildren = new List(); + ms.Root.GetChildren(ms.Context, rootChildren); + Assert.That(rootChildren, Does.Contain(ms.Stage)); + } + + [Test] + public void PrimWithNoAttributes_Materializes_WithNoAttributeChildren() + { + var stage = new UsdStage("Bare") { DefaultPrim = "P" }; + stage.AddRootPrim(new UsdPrim("P", "Xform")); + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + Assert.That(ms.Result.PrimsByPath.ContainsKey("/P"), Is.True); + List attrs = + MaterializationHarness.ChildrenOfType(ms.Context, ms.Prim("/P")); + Assert.That(attrs, Is.Empty); + } + + [Test] + public void AttributeWithNoAuthoredValue_MaterializesTypeButNoValue() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + // Surface.outputs:surface is declared with no authored value. + UsdAttributeState a = + ms.Attr("/Plant/Pumps/P101/StatusLight/Mat/Surface.outputs:surface"); + + Assert.That(a.BoxedValue(), Is.Null); + Assert.That( + a.DataType, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.DataTypes.UsdToken, ms.Namespace))); + Assert.That(a.UsdTypeName!.Value, Is.EqualTo("token")); + } + + // ---- Odd / duplicate BrowseNames ----------------------------------------------- + + [Test] + public void DuplicateAttributeNames_CollapseToSingleVariable_LastWins() + { + var stage = new UsdStage("Dup") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add(new UsdAttribute("dup", "double") { Value = 1.0 }); + prim.Attributes.Add(new UsdAttribute("dup", "token") { Value = "x" }); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + List attrs = + MaterializationHarness.ChildrenOfType(ms.Context, ms.Prim("/P")); + + // OPC UA requires unique BrowseNames among a node's children, so the collision does + // not throw and does not duplicate โ€” it collapses onto one Variable, last author wins. + Assert.That(attrs, Has.Count.EqualTo(1)); + Assert.That(ms.Result.AttributesByPath.ContainsKey("/P.dup"), Is.True); + Assert.That(ms.Attr("/P.dup").UsdTypeName!.Value, Is.EqualTo("token")); + } + + [Test] + public void OddlyNamespacedAttribute_SplitsOnLastColon() + { + var stage = new UsdStage("Odd") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add(new UsdAttribute("a:b:c", "double") { Value = 1.0 }); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdAttributeState a = ms.Attr("/P.a:b:c"); + + Assert.That(a.Namespace!.Value, Is.EqualTo("a:b")); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/MaterializerTests.cs b/tests/Opc.Ua.OpenUsd.Tests/MaterializerTests.cs new file mode 100644 index 0000000000..80d48246e8 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/MaterializerTests.cs @@ -0,0 +1,445 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Materializer tests for scene structure (ยง5, ยง7.1), typed prims (ยง5.3), the ยง6.2 + /// type/value mapping, relationships (ยง5.5) and live attributes (ยง9 Mode A). + /// + [TestFixture] + public class MaterializerTests + { + // ---- Scene structure (ยง5, ยง7.1) ------------------------------------------------ + + [Test] + [TestCase("Plant.usda", "Plant")] + [TestCase("Cell.usda", "Cell")] + public void Stage_CarriesMetadata_AndIsBrowsableFromRoot(string asset, string defaultPrim) + { + UsdStage parsed = TestAssets.Load(asset); + MaterializedScene ms = MaterializationHarness.Materialize(parsed); + + Assert.That(ms.Stage.BrowseName.Name, Is.EqualTo(parsed.StageName)); + Assert.That(ms.Stage.ReferenceTypeId, Is.EqualTo(Opc.Ua.ReferenceTypeIds.HasComponent)); + Assert.That(ms.Stage.NodeId.IsNull, Is.False); + + var rootChildren = new List(); + ms.Root.GetChildren(ms.Context, rootChildren); + Assert.That(rootChildren, Does.Contain(ms.Stage)); + + Assert.That(ms.Stage.DefaultPrim!.Value, Is.EqualTo(defaultPrim)); + Assert.That(ms.Stage.DefaultPrim.Value, Is.EqualTo(parsed.DefaultPrim)); + Assert.That(ms.Stage.UpAxis!.Value, Is.EqualTo(parsed.UpAxis)); + Assert.That(ms.Stage.MetersPerUnit!.Value, Is.EqualTo(parsed.MetersPerUnit)); + } + + [Test] + [TestCase("Plant.usda")] + [TestCase("Cell.usda")] + public void PrimTree_MatchesParsedScene_PrimForPrim(string asset) + { + UsdStage parsed = TestAssets.Load(asset); + MaterializedScene ms = MaterializationHarness.Materialize(parsed); + + List parsedPrims = parsed.AllPrims().ToList(); + Assert.That(ms.Result.PrimsByPath, Has.Count.EqualTo(parsedPrims.Count)); + + foreach (UsdPrim prim in parsedPrims) + { + string path = prim.PathOf(); + Assert.That(ms.Result.PrimsByPath.ContainsKey(path), Is.True, path); + + UsdPrimState node = ms.Prim(path); + Assert.That(node.BrowseName.Name, Is.EqualTo(prim.Name), path); + Assert.That(node.BrowseName.NamespaceIndex, Is.EqualTo(ms.Namespace), path); + Assert.That(node.NodeId.IsNull, Is.False, path); + + // Every prim is a HasComponent child, so the prim tree is the browse tree (ยง7.1). + Assert.That( + node.ReferenceTypeId, + Is.EqualTo(Opc.Ua.ReferenceTypeIds.HasComponent), + path); + if (!string.IsNullOrEmpty(prim.TypeName)) + { + Assert.That(node.TypeName!.Value, Is.EqualTo(prim.TypeName), path); + } + + NodeState parent = prim.Parent == null ? ms.Stage : ms.Prim(prim.Parent.PathOf()); + var children = new List(); + parent.GetChildren(ms.Context, children); + Assert.That(children, Does.Contain(node), path); + } + } + + [Test] + [TestCase("Plant.usda")] + [TestCase("Cell.usda")] + public void AttributeIndex_IsPopulated_WithExpectedKeys(string asset) + { + UsdStage parsed = TestAssets.Load(asset); + MaterializedScene ms = MaterializationHarness.Materialize(parsed); + + int expected = 0; + foreach (UsdPrim prim in parsed.AllPrims()) + { + string path = prim.PathOf(); + foreach (UsdAttribute attribute in prim.Attributes) + { + expected++; + string key = path + "." + attribute.Name; + Assert.That(ms.Result.AttributesByPath.ContainsKey(key), Is.True, key); + } + } + Assert.That(ms.Result.AttributesByPath, Has.Count.EqualTo(expected)); + } + + [Test] + public void KnownAttributeKeys_AreExactlyAsSpecified() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + + // The ยง10 binding index is keyed by absolute SdfPath ".". + Assert.That( + ms.Result.AttributesByPath.ContainsKey("/Plant/Pumps/P101/Impeller.xformOp:rotateZ"), + Is.True); + Assert.That( + ms.Result.AttributesByPath.ContainsKey("/Plant/Pumps/P101/Body.primvars:displayColor"), + Is.True); + } + + // ---- Typed prims (ยง5.3) -------------------------------------------------------- + + [Test] + [TestCase("Xform", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomXformType)] + [TestCase("Scope", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomScopeType)] + [TestCase("Mesh", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomMeshType)] + [TestCase("Cylinder", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomCylinderType)] + [TestCase("Sphere", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomSphereType)] + [TestCase("Cube", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomCubeType)] + [TestCase("Cone", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomConeType)] + [TestCase("Capsule", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomCapsuleType)] + [TestCase("Material", Opc.Ua.OpenUsdScene.ObjectTypes.UsdShadeMaterialType)] + [TestCase("Shader", Opc.Ua.OpenUsdScene.ObjectTypes.UsdShadeShaderType)] + public void TypedPrim_GetsHasTypeDefinition_AndKeepsToken(string typeName, uint objectType) + { + var stage = new UsdStage("Typed") { DefaultPrim = "P" }; + stage.AddRootPrim(new UsdPrim("P", typeName)); + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + UsdPrimState node = ms.Prim("/P"); + Assert.That(node.TypeDefinitionId, Is.EqualTo(new NodeId(objectType, ms.Namespace))); + // The exact USD token survives even when a dedicated ObjectType exists for it. + Assert.That(node.TypeName!.Value, Is.EqualTo(typeName)); + } + + [Test] + public void TypedPrim_TypeDefinitions_HoldOnRealAssets() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + + AssertTypeDefinition(ms, "/Plant", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomXformType); + AssertTypeDefinition(ms, "/Plant/Pumps", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomScopeType); + AssertTypeDefinition( + ms, "/Plant/Pumps/P101/Body", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomCylinderType); + AssertTypeDefinition( + ms, "/Plant/Pumps/P101/Bearing", Opc.Ua.OpenUsdScene.ObjectTypes.UsdGeomSphereType); + AssertTypeDefinition( + ms, + "/Plant/Pumps/P101/StatusLight/Mat", + Opc.Ua.OpenUsdScene.ObjectTypes.UsdShadeMaterialType); + AssertTypeDefinition( + ms, + "/Plant/Pumps/P101/StatusLight/Mat/Surface", + Opc.Ua.OpenUsdScene.ObjectTypes.UsdShadeShaderType); + } + + // ---- Attributes and the ยง6.2 mapping ------------------------------------------- + + [Test] + public void Mapping_DoubleScalar() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + UsdAttributeState a = ms.Attr("/Plant/Pumps/P101/Body.radius"); + + Assert.That(a.DataType, Is.EqualTo(new NodeId(Opc.Ua.DataTypes.Double))); + Assert.That(a.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.Scalar)); + Assert.That(a.Dims(), Is.Null); + Assert.That(a.UsdTypeName!.Value, Is.EqualTo("double")); + Assert.That(a.Variability!.Value, Is.EqualTo(UsdVariabilityEnum.Varying)); + } + + [Test] + public void Mapping_Double3_IsRank1_WithDims3() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + UsdAttributeState a = ms.Attr("/Plant/Pumps/P101.xformOp:translate"); + + Assert.That(a.DataType, Is.EqualTo(new NodeId(Opc.Ua.DataTypes.Double))); + Assert.That(a.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.OneDimension)); + Assert.That(a.Dims(), Is.EqualTo(new uint[] { 3 })); + Assert.That(a.UsdTypeName!.Value, Is.EqualTo("double3")); + // xformOp:rotateZ etc. carry the authored property namespace (ยง5.4). + Assert.That(a.Namespace!.Value, Is.EqualTo("xformOp")); + } + + [Test] + public void Mapping_TokenScalar_UsesUsdTokenDataType() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + UsdAttributeState a = ms.Attr("/Plant/Pumps/P101/Body.axis"); + + Assert.That( + a.DataType, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.DataTypes.UsdToken, ms.Namespace))); + Assert.That(a.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.Scalar)); + Assert.That(a.UsdTypeName!.Value, Is.EqualTo("token")); + // "uniform token axis" โ€” authored uniform. + Assert.That(a.Variability!.Value, Is.EqualTo(UsdVariabilityEnum.Uniform)); + } + + [Test] + public void Mapping_TokenArray_IsRank1() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + UsdAttributeState a = ms.Attr("/Plant/Pumps/P101.xformOpOrder"); + + Assert.That( + a.DataType, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.DataTypes.UsdToken, ms.Namespace))); + Assert.That(a.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.OneDimension)); + Assert.That(a.Dims(), Is.EqualTo(new uint[] { 0 })); + Assert.That(a.UsdTypeName!.Value, Is.EqualTo("token[]")); + } + + [Test] + public void Mapping_Color3f_IsRank1_WithDims3() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + UsdAttributeState a = + ms.Attr("/Plant/Pumps/P101/StatusLight/Mat/Surface.inputs:diffuseColor"); + + Assert.That( + a.DataType, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.DataTypes.UsdColor3f, ms.Namespace))); + Assert.That(a.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.OneDimension)); + Assert.That(a.Dims(), Is.EqualTo(new uint[] { 3 })); + Assert.That(a.UsdTypeName!.Value, Is.EqualTo("color3f")); + Assert.That(a.Namespace!.Value, Is.EqualTo("inputs")); + } + + [Test] + public void Mapping_Color3fArray_IsRank2_WithDims0By3() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + UsdAttributeState a = ms.Attr("/Plant/Pumps/P101/Body.primvars:displayColor"); + + Assert.That( + a.DataType, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.DataTypes.UsdColor3f, ms.Namespace))); + Assert.That(a.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.TwoDimensions)); + Assert.That(a.Dims(), Is.EqualTo(new uint[] { 0, 3 })); + Assert.That(a.UsdTypeName!.Value, Is.EqualTo("color3f[]")); + Assert.That(a.Namespace!.Value, Is.EqualTo("primvars")); + } + + [Test] + public void Mapping_CustomAttribute_CarriesCustomFlag() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + UsdAttributeState a = ms.Attr("/Plant/Pumps/P101/Impeller.inputs:speedSetpoint"); + + Assert.That(a.Custom!.Value, Is.True); + Assert.That(a.Namespace!.Value, Is.EqualTo("inputs")); + } + + [Test] + public void Mapping_NonNamespacedAttribute_HasNoNamespaceProperty() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + UsdAttributeState a = ms.Attr("/Plant/Pumps/P101/Body.radius"); + + // "radius" has no ":" namespace, so the optional Namespace Property is not authored. + Assert.That(a.Namespace, Is.Null); + Assert.That(a.Custom, Is.Null); + } + + // ---- Relationships (ยง5.5) ------------------------------------------------------ + + [Test] + public void Relationship_ResolvableTarget_KeepsPath_AndAddsReference() + { + MaterializedScene ms = MaterializationHarness.Materialize(TestAssets.Load("Plant.usda")); + + UsdPrimState bulb = ms.Prim("/Plant/Pumps/P101/StatusLight/Bulb"); + List rels = + MaterializationHarness.ChildrenOfType(ms.Context, bulb); + Assert.That(rels, Has.Count.EqualTo(1)); + + UsdRelationshipState rel = rels[0]; + Assert.That(rel.BrowseName.Name, Is.EqualTo("material:binding")); + Assert.That( + rel.TargetPaths!.Value.ToArray(), + Is.EqualTo(new[] { "/Plant/Pumps/P101/StatusLight/Mat" })); + + // A target inside the subtree also becomes a browsable UsdRelationshipTarget edge. + NodeId mat = ms.Prim("/Plant/Pumps/P101/StatusLight/Mat").NodeId; + Assert.That(HasReference(rel, ms.Context, RelationshipTargetType(ms), mat), Is.True); + } + + [Test] + public void Relationship_ExternalTarget_KeepsPath_ButHasNoDanglingReference() + { + var stage = new UsdStage("Rel") { DefaultPrim = "A" }; + var a = new UsdPrim("A", "Xform"); + var b = new UsdPrim("B", "Xform"); + var rel = new UsdRelationship("mixed"); + rel.Targets.Add("/A/B"); // resolvable, inside the subtree + rel.Targets.Add("/Outside/Thing"); // outside the materialized subtree + a.Relationships.Add(rel); + a.AddChild(b); + stage.AddRootPrim(a); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + List rels = + MaterializationHarness.ChildrenOfType(ms.Context, ms.Prim("/A")); + UsdRelationshipState node = rels[0]; + + // Ordered paths keep full fidelity, including the unresolvable one. + Assert.That( + node.TargetPaths!.Value.ToArray(), + Is.EqualTo(new[] { "/A/B", "/Outside/Thing" })); + + NodeId inside = ms.Prim("/A/B").NodeId; + Assert.That(HasReference(node, ms.Context, RelationshipTargetType(ms), inside), Is.True); + + // Exactly one resolved edge โ€” the external target produced no dangling reference. + Assert.That(CountReferences(node, ms.Context, RelationshipTargetType(ms)), Is.EqualTo(1)); + } + + [Test] + public void Relationship_ZeroTargets_MaterializesEmptyPathList() + { + var stage = new UsdStage("Rel0") { DefaultPrim = "A" }; + var a = new UsdPrim("A", "Xform"); + a.Relationships.Add(new UsdRelationship("empty")); + stage.AddRootPrim(a); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdRelationshipState node = + MaterializationHarness.ChildrenOfType(ms.Context, ms.Prim("/A"))[0]; + + Assert.That(node.TargetPaths!.Value.ToArray(), Is.Empty); + Assert.That(CountReferences(node, ms.Context, RelationshipTargetType(ms)), Is.Zero); + } + + // ---- Live attributes (ยง9 Mode A) ----------------------------------------------- + + [Test] + public void LiveAttribute_WithHistorize_IsWritable_AndHistorizing() + { + MaterializedScene ms = MaterializationHarness.Materialize( + LiveStage(), new UsdMaterializationOptions { HistorizeLiveAttributes = true }); + + UsdAttributeState a = ms.Attr("/Rig.speed"); + Assert.That(a.Historizing, Is.True); + Assert.That(a.AccessLevel & Opc.Ua.AccessLevels.CurrentWrite, Is.Not.Zero); + Assert.That(a.AccessLevel & Opc.Ua.AccessLevels.HistoryRead, Is.Not.Zero); + } + + [Test] + public void LiveAttribute_WithoutHistorize_IsWritable_ButNotHistorizing() + { + MaterializedScene ms = MaterializationHarness.Materialize( + LiveStage(), new UsdMaterializationOptions { HistorizeLiveAttributes = false }); + + UsdAttributeState a = ms.Attr("/Rig.speed"); + Assert.That(a.Historizing, Is.False); + Assert.That(a.AccessLevel & Opc.Ua.AccessLevels.CurrentWrite, Is.Not.Zero); + Assert.That(a.AccessLevel & Opc.Ua.AccessLevels.HistoryRead, Is.Zero); + } + + // ---- helpers ------------------------------------------------------------------- + + private static UsdStage LiveStage() + { + var stage = new UsdStage("Live") { DefaultPrim = "Rig" }; + var rig = new UsdPrim("Rig", "Xform"); + rig.Attributes.Add(new UsdAttribute("speed", "double") { Value = 0.0, Live = true }); + stage.AddRootPrim(rig); + return stage; + } + + private static void AssertTypeDefinition(MaterializedScene ms, string path, uint objectType) + { + Assert.That( + ms.Prim(path).TypeDefinitionId, + Is.EqualTo(new NodeId(objectType, ms.Namespace)), + path); + } + + private static NodeId RelationshipTargetType(MaterializedScene ms) + { + return ExpandedNodeId.ToNodeId( + Opc.Ua.OpenUsdScene.ReferenceTypeIds.UsdRelationshipTarget, ms.Context.NamespaceUris); + } + + private static bool HasReference( + NodeState node, ISystemContext context, NodeId referenceType, NodeId target) + { + return CountReferences(node, context, referenceType, target) > 0; + } + + private static int CountReferences( + NodeState node, ISystemContext context, NodeId referenceType, NodeId? target = null) + { + var references = new List(); + node.GetReferences(context, references); + int count = 0; + foreach (IReference reference in references) + { + if (reference.ReferenceTypeId != referenceType || reference.IsInverse) + { + continue; + } + if (target == null || reference.TargetId == new ExpandedNodeId(target.Value)) + { + count++; + } + } + return count; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/MetadataCoercionTests.cs b/tests/Opc.Ua.OpenUsd.Tests/MetadataCoercionTests.cs new file mode 100644 index 0000000000..b19c59b21d --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/MetadataCoercionTests.cs @@ -0,0 +1,346 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; +using System.Collections.Generic; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Unit tests for the ยง6.3 metadata coercion rules of the materializer: every CLR scalar + /// kind keeps its own OPC UA DataType, a homogeneous sequence keeps its element type, and + /// anything the mapping cannot represent falls back to its invariant textual form rather + /// than being dropped or guessed at. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class MetadataCoercionTests + { + [Test] + public void AnEntryWithAnEmptyKeyIsSkipped() + { + var prim = new UsdPrim("Cube", "Cube"); + prim.Metadata[string.Empty] = "dropped"; + prim.Metadata["kept"] = "value"; + + List properties = MetadataProperties(prim); + + Assert.That(properties, Has.Count.EqualTo(1)); + Assert.That(properties[0].BrowseName.Name, Is.EqualTo("kept")); + } + + [Test] + public void AnEntryWithANullValueBecomesAValuelessProperty() + { + var prim = new UsdPrim("Cube", "Cube"); + prim.Metadata["missing"] = null; + + PropertyState property = SingleMetadataProperty(prim); + + Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.BaseDataType)); + Assert.That(property.Value.IsNull, Is.True); + } + + [TestCase((sbyte)-8, Opc.Ua.DataTypes.SByte)] + [TestCase((byte)8, Opc.Ua.DataTypes.Byte)] + [TestCase((short)-16, Opc.Ua.DataTypes.Int16)] + [TestCase((ushort)16, Opc.Ua.DataTypes.UInt16)] + [TestCase(32u, Opc.Ua.DataTypes.UInt32)] + [TestCase(64UL, Opc.Ua.DataTypes.UInt64)] + [TestCase(1.5f, Opc.Ua.DataTypes.Float)] + public void AScalarKeepsItsOwnDataType(object value, uint expectedDataType) + { + var prim = new UsdPrim("Cube", "Cube"); + prim.Metadata["scalar"] = value; + + PropertyState property = SingleMetadataProperty(prim); + + Assert.That(property.ValueRank, Is.EqualTo(ValueRanks.Scalar)); + Assert.That( + property.DataType, + Is.EqualTo(new NodeId(expectedDataType)), + "The materialized DataType must be the one the CLR value maps to."); + } + + [Test] + public void AnUnrepresentableScalarIsCarriedAsInvariantText() + { + var prim = new UsdPrim("Cube", "Cube"); + var stamp = new DateTime(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc); + prim.Metadata["stamp"] = new UnprintableMetadata(stamp); + + PropertyState property = SingleMetadataProperty(prim); + + Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.String)); + Assert.That(property.Value.TryGetValue(out string text), Is.True); + Assert.That(text, Is.EqualTo(stamp.ToString("O"))); + } + + [Test] + public void ANestedWriteOnlyDictionaryBecomesASubFolder() + { + var prim = new UsdPrim("Cube", "Cube"); + var nested = new WriteOnlyMetadataDictionary { ["vendor"] = "Contoso" }; + prim.Metadata["customData"] = nested; + + MaterializedScene scene = Materialize(prim); + FolderState metadata = MetadataFolder(scene); + List folders = + MaterializationHarness.ChildrenOfType(scene.Context, metadata); + + Assert.That(folders, Has.Count.EqualTo(1)); + Assert.That(folders[0].BrowseName.Name, Is.EqualTo("customData")); + List inner = + MaterializationHarness.ChildrenOfType(scene.Context, folders[0]); + Assert.That(inner, Has.Count.EqualTo(1)); + Assert.That(inner[0].BrowseName.Name, Is.EqualTo("vendor")); + } + + [Test] + public void ABooleanSequenceKeepsItsElementType() + { + PropertyState property = SingleMetadataProperty( + WithMetadata("flags", new[] { true, false, true })); + + Assert.That(property.ValueRank, Is.EqualTo(ValueRanks.OneDimension)); + Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Boolean)); + Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); + Assert.That(values.Count, Is.EqualTo(3)); + Assert.That(values[1], Is.False); + } + + [Test] + public void ALongSequenceKeepsItsElementType() + { + PropertyState property = SingleMetadataProperty( + WithMetadata("ticks", new[] { 9_000_000_000L, 2L })); + + Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int64)); + Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); + Assert.That(values[0], Is.EqualTo(9_000_000_000L)); + } + + [Test] + public void AnUnsignedSequenceWidensToInt64() + { + PropertyState property = SingleMetadataProperty( + WithMetadata("ids", new[] { 1u, 2u })); + + Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int64)); + Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); + Assert.That(values.Count, Is.EqualTo(2)); + } + + [Test] + public void AFloatingPointSequenceKeepsItsElementType() + { + PropertyState property = SingleMetadataProperty( + WithMetadata("scales", new[] { 1.5f, 2.5f })); + + Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Double)); + Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); + Assert.That(values[0], Is.EqualTo(1.5).Within(1e-6)); + } + + [Test] + public void AnEmptySequenceFallsBackToAStringSequence() + { + PropertyState property = SingleMetadataProperty( + WithMetadata("tags", Array.Empty())); + + Assert.That(property.ValueRank, Is.EqualTo(ValueRanks.OneDimension)); + Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.String)); + Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); + Assert.That(values.Count, Is.Zero); + } + + [Test] + public void ASequenceHoldingANullElementFallsBackToText() + { + PropertyState property = SingleMetadataProperty( + WithMetadata("order", new object?[] { 3, null })); + + Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.String)); + Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); + Assert.That(values.Count, Is.EqualTo(2)); + Assert.That(values[0], Is.EqualTo("3")); + Assert.That(values[1], Is.Empty); + } + + [Test] + public void ASequenceWithAnUnconvertibleElementFallsBackToText() + { + PropertyState property = SingleMetadataProperty( + WithMetadata("flags", new object[] { true, "not-a-boolean" })); + + Assert.That(property.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.String)); + Assert.That(property.Value.TryGetValue(out ArrayOf values), Is.True); + Assert.That(values[1], Is.EqualTo("not-a-boolean")); + } + + private static UsdPrim WithMetadata(string key, object value) + { + var prim = new UsdPrim("Cube", "Cube"); + prim.Metadata[key] = value; + return prim; + } + + private static MaterializedScene Materialize(UsdPrim prim) + { + var stage = new UsdStage("Test"); + stage.AddRootPrim(prim); + return MaterializationHarness.Materialize(stage); + } + + private static FolderState MetadataFolder(MaterializedScene scene) + { + List prims = + MaterializationHarness.ChildrenOfType(scene.Context, scene.Stage); + Assert.That(prims, Has.Count.EqualTo(1)); + FolderState? metadata = prims[0].Metadata; + Assert.That(metadata, Is.Not.Null); + return metadata!; + } + + private static List MetadataProperties(UsdPrim prim) + { + MaterializedScene scene = Materialize(prim); + return MaterializationHarness.ChildrenOfType( + scene.Context, MetadataFolder(scene)); + } + + private static PropertyState SingleMetadataProperty(UsdPrim prim) + { + List properties = MetadataProperties(prim); + Assert.That(properties, Has.Count.EqualTo(1)); + return properties[0]; + } + + /// + /// A metadata value of a kind the ยง6.3 mapping does not represent, so the materializer + /// must fall back to its invariant textual form. + /// + private sealed class UnprintableMetadata + { + private readonly DateTime m_stamp; + + public UnprintableMetadata(DateTime stamp) + { + m_stamp = stamp; + } + + public override string ToString() + { + return m_stamp.ToString("O"); + } + } + + /// + /// A dictionary that implements only the mutable dictionary contract, so the nested + /// customData detection has to fall through to its second, read-write case. + /// + private sealed class WriteOnlyMetadataDictionary : IDictionary + { + private readonly Dictionary m_inner = + new Dictionary(StringComparer.Ordinal); + + public object? this[string key] + { + get => m_inner[key]; + set => m_inner[key] = value; + } + + public ICollection Keys => m_inner.Keys; + + public ICollection Values => m_inner.Values; + + public int Count => m_inner.Count; + + public bool IsReadOnly => false; + + public void Add(string key, object? value) + { + m_inner.Add(key, value); + } + + public void Add(KeyValuePair item) + { + m_inner.Add(item.Key, item.Value); + } + + public void Clear() + { + m_inner.Clear(); + } + + public bool Contains(KeyValuePair item) + { + return m_inner.TryGetValue(item.Key, out object? found) && Equals(found, item.Value); + } + + public bool ContainsKey(string key) + { + return m_inner.ContainsKey(key); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + ((ICollection>)m_inner).CopyTo(array, arrayIndex); + } + + public IEnumerator> GetEnumerator() + { + return m_inner.GetEnumerator(); + } + + public bool Remove(string key) + { + return m_inner.Remove(key); + } + + public bool Remove(KeyValuePair item) + { + return m_inner.Remove(item.Key); + } + + public bool TryGetValue(string key, out object? value) + { + return m_inner.TryGetValue(key, out value); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return m_inner.GetEnumerator(); + } + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/MockUsdSinkTests.cs b/tests/Opc.Ua.OpenUsd.Tests/MockUsdSinkTests.cs new file mode 100644 index 0000000000..55a7619f56 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/MockUsdSinkTests.cs @@ -0,0 +1,114 @@ +/* ======================================================================== + * 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 NUnit.Framework; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Unit tests for and . + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class MockUsdSinkTests + { + [Test] + public void SetAttributeTracksWritesAndValue() + { + var sink = new MockUsdSink(); + sink.SetAttribute("/Pump", "radius", new Variant(2.5)); + + Assert.That(sink.TotalWrites, Is.EqualTo(1)); + Assert.That(sink.WasWritten("/Pump", "radius"), Is.True); + Assert.That(sink.TryGetWritten("/Pump", "radius", out Variant value), Is.True); + Assert.That(value.TryGetValue(out double d), Is.True); + Assert.That(d, Is.EqualTo(2.5).Within(1e-9)); + } + + [Test] + public void SetTimeSampleTracksTimeSampleWrites() + { + var sink = new MockUsdSink(); + sink.SetTimeSample("/Pump", "radius", new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), new Variant(1.0)); + + Assert.That(sink.TimeSampleWrites, Is.EqualTo(1)); + } + + [Test] + public void ComposePrimTracksActiveAndCount() + { + var sink = new MockUsdSink(); + sink.ComposePrim("/Pump", OpenUsdCompositionArc.Reference, "@pump.usda@", active: true); + sink.ComposePrim("/Tool", OpenUsdCompositionArc.Child, null, active: false); + + Assert.That(sink.WasPrimComposed("/Pump"), Is.True); + Assert.That(sink.IsPrimActive("/Pump"), Is.True); + Assert.That(sink.IsPrimActive("/Tool"), Is.False); + Assert.That(sink.ComposedPrimCount, Is.EqualTo(2)); + } + + [Test] + public void TryGetWrittenReturnsFalseForUnknownProperty() + { + var sink = new MockUsdSink(); + + Assert.That(sink.TryGetWritten("/Nope", "x", out Variant value), Is.False); + Assert.That(value.IsNull, Is.True); + } + + [Test] + public void BeginBatchReturnsNoOpScope() + { + var sink = new MockUsdSink(); + + using IDisposable batch = sink.BeginBatch(); + Assert.That(batch, Is.Not.Null); + } + } + + /// + /// Unit tests for defaults. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorOptionsTests + { + [Test] + public void DefaultsAreFailClosedAndBounded() + { + var options = new OpenUsdConnectorOptions(); + + Assert.That(options.EnableCommands, Is.False); + Assert.That(options.RemoteSessionFactory, Is.Null); + Assert.That(options.MaxAssetBytes, Is.EqualTo(64 * 1024 * 1024)); + Assert.That(options.MaxTotalAssetBytes, Is.EqualTo(256L * 1024 * 1024)); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/Opc.Ua.OpenUsd.Tests.csproj b/tests/Opc.Ua.OpenUsd.Tests/Opc.Ua.OpenUsd.Tests.csproj new file mode 100644 index 0000000000..da787c8c9c --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/Opc.Ua.OpenUsd.Tests.csproj @@ -0,0 +1,50 @@ + + + $(TestsTargetFrameworks) + Opc.Ua.OpenUsd.Tests + Opc.Ua.OpenUsd.Tests + enable + false + $(NoWarn);CS1591;CA2007;CA2000;CA1014;CA1861 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + PreserveNewest + + + + + + PreserveNewest + + + diff --git a/tests/Opc.Ua.OpenUsd.Tests/OpenUsdAuthoringHarness.cs b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdAuthoringHarness.cs new file mode 100644 index 0000000000..17ad58ffd6 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdAuthoringHarness.cs @@ -0,0 +1,131 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.OpenUsd; + +namespace Opc.Ua.OpenUsd.Server.Tests +{ + /// + /// A NodeIdFactory that hands each newly authored node a fresh numeric identifier in a + /// dedicated instance namespace, mirroring what a real NodeManager does while a subtree + /// is wired up. Nodes that already carry an identifier keep it. + /// + internal sealed class SequentialOpenUsdNodeIdFactory : INodeIdFactory + { + private readonly ushort m_namespaceIndex; + private uint m_next; + + public SequentialOpenUsdNodeIdFactory(ushort namespaceIndex) + { + m_namespaceIndex = namespaceIndex; + } + + public NodeId New(ISystemContext context, NodeState node) + { + return node.NodeId.IsNull ? new NodeId(++m_next, m_namespaceIndex) : node.NodeId; + } + } + + /// + /// Builds an in-process carrying the draft OpenUSD Bindings + /// companion namespace plus the well-known OpenUSD facility (stages and + /// representations registries), so the server-side authoring helpers can be exercised + /// against a real tree without standing up an opc.tcp + /// server. + /// + internal static class OpenUsdAuthoringHarness + { + /// + /// A private namespace the instance NodeIds are minted in. + /// + public const string InstanceNamespaceUri = "urn:opcfoundation:openusd:tests:bindings"; + + /// + /// Creates a fresh context and the OpenUSD companion namespace index. + /// + public static (SystemContext Context, ushort Namespace) NewContext() + { + var namespaces = new NamespaceTable(); + var ns = (ushort)namespaces.GetIndexOrAppend(Namespaces.OpenUSD); + var instanceNs = (ushort)namespaces.GetIndexOrAppend(InstanceNamespaceUri); + + var context = new SystemContext(null!) + { + NamespaceUris = namespaces, + ServerUris = new StringTable(), + NodeIdFactory = new SequentialOpenUsdNodeIdFactory(instanceNs) + }; + return (context, ns); + } + + /// + /// Materialises the well-known OpenUSD facility with its Stages and + /// Representations registries. + /// + public static OpenUsdRootState NewFacility(SystemContext context, ushort ns) + { + OpenUsdRootState root = context.CreateInstanceOfOpenUsdRootType( + null!, new QualifiedName("OpenUSD", ns)); + root.NodeId = new NodeId("OpenUSD", ns); + _ = root.Stages ?? root.CreateOrReplaceStages(context, null!); + _ = root.Representations ?? root.CreateOrReplaceRepresentations(context, null!); + return root; + } + + /// + /// Adds an OpenUsdStageType instance to the facility's stages folder. + /// + public static OpenUsdStageState NewStage( + SystemContext context, OpenUsdRootState root, ushort ns, string name) + { + FolderState stages = root.Stages!; + OpenUsdStageState stage = context.CreateInstanceOfOpenUsdStageType( + stages, new QualifiedName(name, ns)); + stage.ReferenceTypeId = ReferenceTypeIds.HasComponent; + stages.AddChild(stage); + stage.NodeId = context.NodeIdFactory.New(context, stage); + return stage; + } + + /// + /// Creates a plain Object a representation AddIn can be attached to. + /// + public static BaseObjectState NewOwner(SystemContext context, ushort ns, string name) + { + var owner = new BaseObjectState(null) + { + BrowseName = new QualifiedName(name, ns), + DisplayName = new LocalizedText(name), + TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType + }; + owner.NodeId = context.NodeIdFactory.New(context, owner); + return owner; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorAssetsTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorAssetsTests.cs new file mode 100644 index 0000000000..98ceb48052 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorAssetsTests.cs @@ -0,0 +1,559 @@ +/* ======================================================================== + * Copyright (c) 2005-2024 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.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Client; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Covers ยง5.15 served-asset delivery: streaming layers through the Part 5 + /// FileType, digest verification, cache writing and the root-layer fallback. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorAssetsTests + { + private FakeAddressSpace m_space = null!; + private Mock m_session = null!; + private MockUsdSink m_sink = null!; + private string m_cacheDir = null!; + + [SetUp] + public void SetUp() + { + m_space = new FakeAddressSpace(); + m_session = FakeSession.Create(m_space); + m_sink = new MockUsdSink(); + m_cacheDir = Path.Combine(Path.GetTempPath(), "usdassets-" + Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(m_cacheDir)) + { + Directory.Delete(m_cacheDir, recursive: true); + } + } + + private OpenUsdConnector Connector(OpenUsdConnectorOptions? options = null) + { + return new OpenUsdConnector(m_session.Object, m_sink, + options ?? new OpenUsdConnectorOptions()); + } + + private NodeId RepresentationTypeId + => new(OpenUsdModel.RepresentationTypeId, m_space.OpenUsdNamespaceIndex); + + private NodeId AssetTypeId + => new(OpenUsdModel.AssetTypeId, m_space.OpenUsdNamespaceIndex); + + private NodeId AddRegistry() + { + NodeId facility = m_space.AddObject(Opc.Ua.ObjectIds.Server, "OpenUSD", + browseNameNamespace: m_space.OpenUsdNamespaceIndex); + return m_space.AddObject(facility, "Representations"); + } + + private NodeId AddRepresentation(string name = "Robot") + { + NodeId rep = m_space.AddObject(AddRegistry(), name, RepresentationTypeId); + m_space.AddVariable(rep, "PrimPath", new Variant("/World/" + name)); + return rep; + } + + private NodeId AddStage(NodeId rep, string rootLayerIdentifier = "robot.usda") + { + NodeId stage = m_space.AddObject(rep, "Stage-Object"); + m_space.AddVariable(rep, "Stage", new Variant(stage)); + m_space.AddVariable(stage, "RootLayerIdentifier", new Variant(rootLayerIdentifier)); + return stage; + } + + private NodeId AddAsset( + NodeId assetsFolder, + string identifier, + byte[] content, + OpenUsdAssetKind kind = OpenUsdAssetKind.SubLayer, + ByteString digest = default, + NodeId typeDefinition = default, + bool withIdentifier = true) + { + NodeId asset = m_space.AddObject(assetsFolder, "Asset-" + identifier, + typeDefinition.IsNull ? AssetTypeId : typeDefinition); + if (withIdentifier) + { + m_space.AddVariable(asset, "AssetIdentifier", new Variant(identifier)); + } + m_space.AddVariable(asset, "AssetKind", new Variant((int)kind)); + if (!digest.IsNull) + { + m_space.AddVariable(asset, "Digest", new Variant(digest)); + m_space.AddVariable(asset, "DigestAlgorithm", + new Variant((int)OpenUsdDigestAlgorithm.Sha256)); + } + m_space.AddPart5File(asset, content); + return asset; + } + + private static ByteString Sha256Of(byte[] bytes) + { + return new ByteString(Hash(bytes, OpenUsdDigestAlgorithm.Sha256)); + } + + private static byte[] Hash(byte[] bytes, OpenUsdDigestAlgorithm algorithm) + { +#pragma warning disable CA1850 // Prefer static HashData (net48 compatibility) + using HashAlgorithm hash = algorithm switch + { + OpenUsdDigestAlgorithm.Sha384 => SHA384.Create(), + OpenUsdDigestAlgorithm.Sha512 => SHA512.Create(), + _ => SHA256.Create() + }; + return hash.ComputeHash(bytes); +#pragma warning restore CA1850 + } + + [Test] + public async Task FetchServedAssetsReturnsEmptyWhenNoRepresentationHasAStageAsync() + { + AddRepresentation(); + OpenUsdConnector connector = Connector(); + + List assets = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(assets, Is.Empty); + Assert.That(Directory.Exists(m_cacheDir), Is.True); + } + + [Test] + public async Task FetchServedAssetsReturnsEmptyWhenTheStageServesNoAssetsAsync() + { + AddStage(AddRepresentation()); + OpenUsdConnector connector = Connector(); + + List assets = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(assets, Is.Empty); + } + + [Test] + public async Task FetchServedAssetsSkipsAssetsFolderChildrenOfAForeignTypeAsync() + { + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "foreign.usda", Encoding.UTF8.GetBytes("#usda 1.0"), + typeDefinition: new NodeId(9999)); + OpenUsdConnector connector = Connector(); + + List fetched = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(fetched, Is.Empty); + } + + [Test] + public async Task FetchServedAssetsSkipsAssetsWithoutAnIdentifierAsync() + { + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "nameless.usda", Encoding.UTF8.GetBytes("#usda 1.0"), + withIdentifier: false); + OpenUsdConnector connector = Connector(); + + List fetched = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(fetched, Is.Empty); + } + + [Test] + public async Task FetchServedAssetsWritesTheLayerAndReportsTheDigestVerifiedAsync() + { + byte[] content = Encoding.UTF8.GetBytes("#usda 1.0\ndef Xform \"Robot\" {}\n"); + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "layers/robot.usda", content, + OpenUsdAssetKind.RootLayer, Sha256Of(content)); + OpenUsdConnector connector = Connector(); + + List fetched = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(fetched, Has.Count.EqualTo(1)); + Assert.That(fetched[0].Identifier, Is.EqualTo("layers/robot.usda")); + Assert.That(fetched[0].Kind, Is.EqualTo(OpenUsdAssetKind.RootLayer)); + Assert.That(fetched[0].Length, Is.EqualTo(content.Length)); + Assert.That(fetched[0].DigestVerified, Is.True); + Assert.That(File.Exists(fetched[0].LocalPath), Is.True); + Assert.That(File.ReadAllBytes(fetched[0].LocalPath), Is.EqualTo(content)); + Assert.That(m_space.CloseCount, Is.EqualTo(1)); + } + + [Test] + public async Task FetchServedAssetsDeduplicatesLayersSharedAcrossRepresentationsAsync() + { + byte[] content = Encoding.UTF8.GetBytes("#usda 1.0"); + NodeId shared = AddStage(AddRepresentation("A")); + NodeId assets = m_space.AddObject(shared, "Assets"); + AddAsset(assets, "shared.usda", content, digest: Sha256Of(content)); + AddAsset(assets, "shared.usda", content, digest: Sha256Of(content)); + OpenUsdConnector connector = Connector(); + + List fetched = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(fetched, Has.Count.EqualTo(1)); + } + + [Test] + public void FetchServedAssetsThrowsWhenTheDigestDoesNotMatch() + { + byte[] content = Encoding.UTF8.GetBytes("#usda 1.0"); + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "tampered.usda", content, + digest: new ByteString(new byte[] { 9, 9, 9 })); + OpenUsdConnector connector = Connector(); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None)); + + Assert.That(ex!.Message, Does.Contain("failed digest verification")); + } + + [Test] + public void FetchServedAssetsThrowsWhenADigestIsMissingAndDigestsAreRequired() + { + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "undigested.usda", Encoding.UTF8.GetBytes("#usda 1.0")); + OpenUsdConnector connector = Connector(); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None)); + + Assert.That(ex!.Message, Does.Contain("without a digest")); + } + + [Test] + public async Task FetchServedAssetsSurfacesAnUnverifiedLayerWhenDigestsAreNotRequiredAsync() + { + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "undigested.usda", Encoding.UTF8.GetBytes("#usda 1.0")); + OpenUsdConnector connector = Connector( + new OpenUsdConnectorOptions { RequireAssetDigests = false }); + + List fetched = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(fetched, Has.Count.EqualTo(1)); + Assert.That(fetched[0].DigestVerified, Is.False); + } + + [Test] + public void FetchServedAssetsThrowsWhenTheClosureExceedsTheTotalSizeLimit() + { + byte[] content = Encoding.UTF8.GetBytes("#usda 1.0"); + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "big.usda", content, digest: Sha256Of(content)); + OpenUsdConnector connector = Connector(new OpenUsdConnectorOptions + { + MaxTotalAssetBytes = 2 + }); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None)); + + Assert.That(ex!.Message, Does.Contain("maximum total size")); + } + + [Test] + public void FetchServedAssetsThrowsWhenALayerExceedsTheSingleAssetSizeLimit() + { + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "big.usda", Encoding.UTF8.GetBytes("#usda 1.0 padding padding")); + OpenUsdConnector connector = Connector(new OpenUsdConnectorOptions + { + MaxAssetBytes = 4 + }); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None)); + + Assert.That(ex!.Message, Does.Contain("maximum size")); + } + + [Test] + public void FetchServedAssetsThrowsWhenTheAssetIsMissingPart5Methods() + { + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + NodeId asset = m_space.AddObject(assets, "Asset-broken", AssetTypeId); + m_space.AddVariable(asset, "AssetIdentifier", new Variant("broken.usda")); + OpenUsdConnector connector = Connector(); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None)); + + Assert.That(ex!.Message, Does.Contain("Part 5 Open/Read/Close")); + } + + [Test] + public void FetchServedAssetsThrowsWhenOpenReturnsNoOutputArguments() + { + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "nohandle.usda", Encoding.UTF8.GetBytes("#usda 1.0")); + m_space.OpenResultOverride = []; + OpenUsdConnector connector = Connector(); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None)); + + Assert.That(ex!.Message, Does.Contain("no file handle")); + } + + [Test] + public void FetchServedAssetsThrowsWhenOpenReturnsANonNumericHandle() + { + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "badhandle.usda", Encoding.UTF8.GetBytes("#usda 1.0")); + m_space.OpenResultOverride = [new Variant("not-a-handle")]; + OpenUsdConnector connector = Connector(); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None)); + + Assert.That(ex!.Message, Does.Contain("no file handle")); + } + + [Test] + public async Task FetchServedAssetsCollapsesTraversingIdentifiersIntoTheCacheRootAsync() + { + byte[] content = Encoding.UTF8.GetBytes("#usda 1.0"); + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "@../../evil.usda@", content, digest: Sha256Of(content)); + OpenUsdConnector connector = Connector(); + + List fetched = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(fetched, Has.Count.EqualTo(1)); + Assert.That(Path.GetFileName(fetched[0].LocalPath), Is.EqualTo("evil.usda")); + Assert.That(Path.GetDirectoryName(fetched[0].LocalPath), + Is.EqualTo(Path.GetFullPath(m_cacheDir))); + } + + [Test] + public async Task FetchServedAssetsStripsAnchorSuffixesAndDriveQualifiersAsync() + { + byte[] content = Encoding.UTF8.GetBytes("#usda 1.0"); + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, @"C:\sub\cell.usda", content, digest: Sha256Of(content)); + OpenUsdConnector connector = Connector(); + + List fetched = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(fetched, Has.Count.EqualTo(1)); + Assert.That(Path.GetFileName(fetched[0].LocalPath), Is.EqualTo("cell.usda")); + Assert.That(fetched[0].LocalPath, Does.Contain("sub")); + } + + [Test] + public async Task FetchServedAssetsFallsBackToADefaultNameForAnEmptyIdentifierAsync() + { + byte[] content = Encoding.UTF8.GetBytes("#usda 1.0"); + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "..", content, digest: Sha256Of(content)); + OpenUsdConnector connector = Connector(); + + List fetched = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(fetched, Has.Count.EqualTo(1)); + Assert.That(Path.GetFileName(fetched[0].LocalPath), Is.EqualTo("asset.usda")); + } + + [Test] + public async Task FetchServedAssetsStreamsLayersLargerThanOneChunkAsync() + { + var content = new byte[8192 + 137]; + for (int i = 0; i < content.Length; i++) + { + content[i] = (byte)(i % 251); + } + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "large.usda", content, digest: Sha256Of(content)); + OpenUsdConnector connector = Connector(); + + List fetched = + await connector.FetchServedAssetsAsync(m_cacheDir, CancellationToken.None); + + Assert.That(fetched, Has.Count.EqualTo(1)); + Assert.That(fetched[0].Length, Is.EqualTo(content.Length)); + Assert.That(File.ReadAllBytes(fetched[0].LocalPath), Is.EqualTo(content)); + } + + [Test] + public async Task TryReadRootLayerBytesReturnsNullWithoutAStageAsync() + { + AddRepresentation(); + OpenUsdConnector connector = Connector(); + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + byte[]? bytes = await connector.TryReadRootLayerBytesAsync(reps[0], CancellationToken.None); + + Assert.That(bytes, Is.Null); + } + + [Test] + public async Task TryReadRootLayerBytesReturnsNullWhenTheStageServesNoAssetsAsync() + { + AddStage(AddRepresentation()); + OpenUsdConnector connector = Connector(); + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + byte[]? bytes = await connector.TryReadRootLayerBytesAsync(reps[0], CancellationToken.None); + + Assert.That(bytes, Is.Null); + } + + [Test] + public async Task TryReadRootLayerBytesStreamsTheRootLayerAssetAsync() + { + byte[] content = Encoding.UTF8.GetBytes("#usda 1.0\n"); + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "robot.usda", content, OpenUsdAssetKind.RootLayer); + OpenUsdConnector connector = Connector(); + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + byte[]? bytes = await connector.TryReadRootLayerBytesAsync(reps[0], CancellationToken.None); + + Assert.That(bytes, Is.EqualTo(content)); + } + + [Test] + public async Task TryReadRootLayerBytesFallsBackToTheMatchingIdentifierAsync() + { + byte[] content = Encoding.UTF8.GetBytes("#usda 1.0 root\n"); + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + m_space.AddObject(assets, "Asset-foreign", new NodeId(9999)); + AddAsset(assets, "robot.usda", content, OpenUsdAssetKind.SubLayer); + OpenUsdConnector connector = Connector(); + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + byte[]? bytes = await connector.TryReadRootLayerBytesAsync(reps[0], CancellationToken.None); + + Assert.That(bytes, Is.EqualTo(content)); + } + + [Test] + public async Task TryReadRootLayerBytesReturnsNullWhenNoServedLayerMatchesAsync() + { + NodeId assets = m_space.AddObject(AddStage(AddRepresentation()), "Assets"); + AddAsset(assets, "other.usda", Encoding.UTF8.GetBytes("#usda 1.0"), + OpenUsdAssetKind.SubLayer); + OpenUsdConnector connector = Connector(); + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + byte[]? bytes = await connector.TryReadRootLayerBytesAsync(reps[0], CancellationToken.None); + + Assert.That(bytes, Is.Null); + } + + [Test] + public void VerifyDeliveredAssetAcceptsAMatchingDigest() + { + byte[] bytes = Encoding.UTF8.GetBytes("payload"); + + bool verified = OpenUsdConnector.VerifyDeliveredAsset( + "a.usda", bytes, Sha256Of(bytes), OpenUsdDigestAlgorithm.Sha256, true); + + Assert.That(verified, Is.True); + } + + [Test] + public void VerifyDeliveredAssetRejectsADigestOfTheWrongAlgorithm() + { + byte[] bytes = Encoding.UTF8.GetBytes("payload"); + + Assert.That( + () => OpenUsdConnector.VerifyDeliveredAsset( + "a.usda", bytes, Sha256Of(bytes), OpenUsdDigestAlgorithm.Sha384, true), + Throws.InstanceOf()); + } + + [TestCase(OpenUsdDigestAlgorithm.Sha384)] + [TestCase(OpenUsdDigestAlgorithm.Sha512)] + public void VerifyDeliveredAssetSupportsTheStrongerDigestAlgorithms( + OpenUsdDigestAlgorithm algorithm) + { + byte[] bytes = Encoding.UTF8.GetBytes("payload"); + + bool verified = OpenUsdConnector.VerifyDeliveredAsset( + "a.usda", bytes, new ByteString(Hash(bytes, algorithm)), algorithm, true); + + Assert.That(verified, Is.True); + } + + [Test] + public void VerifyDeliveredAssetRejectsADigestOfTheSameLengthButDifferentContent() + { + byte[] bytes = Encoding.UTF8.GetBytes("payload"); + byte[] wrong = Sha256Of(Encoding.UTF8.GetBytes("other")).ToArray(); + + Assert.That( + () => OpenUsdConnector.VerifyDeliveredAsset( + "a.usda", bytes, new ByteString(wrong), OpenUsdDigestAlgorithm.Sha256, true), + Throws.InstanceOf()); + } + + [Test] + public void VerifyDeliveredAssetTreatsAnEmptyDigestAsAbsent() + { + byte[] bytes = Encoding.UTF8.GetBytes("payload"); + + bool verified = OpenUsdConnector.VerifyDeliveredAsset( + "a.usda", bytes, new ByteString(Array.Empty()), OpenUsdDigestAlgorithm.Sha256, false); + + Assert.That(verified, Is.False); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorCommandTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorCommandTests.cs new file mode 100644 index 0000000000..811311029e --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorCommandTests.cs @@ -0,0 +1,447 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Client; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Drives ยง5.10 command actuation and the ยง9 fail-closed authorisation checks of + /// against an in-memory address space. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorCommandTests + { + private FakeAddressSpace m_space = null!; + private Mock m_session = null!; + private MockUsdSink m_sink = null!; + private NodeId m_binding = NodeId.Null; + private NodeId m_setpoint = NodeId.Null; + private NodeId m_method = NodeId.Null; + + [SetUp] + public void SetUp() + { + m_space = new FakeAddressSpace(); + m_session = FakeSession.Create(m_space); + m_sink = new MockUsdSink(); + + NodeId facility = m_space.AddObject(Opc.Ua.ObjectIds.Server, "OpenUSD", + browseNameNamespace: m_space.OpenUsdNamespaceIndex); + NodeId registry = m_space.AddObject(facility, "Representations"); + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + m_setpoint = m_space.AddVariable(machine, "Setpoint", new Variant(0.0)); + m_method = m_space.AddMethod(machine, "Start"); + NodeId rep = m_space.AddObject(registry, "Robot", + new NodeId(OpenUsdModel.RepresentationTypeId, m_space.OpenUsdNamespaceIndex)); + m_space.AddVariable(rep, "PrimPath", new Variant("/World/Robot")); + m_binding = m_space.AddObject(rep, "Command", + new NodeId(OpenUsdModel.CommandBindingTypeId, m_space.OpenUsdNamespaceIndex)); + m_space.AddVariable(m_binding, "SignalRole", new Variant(1)); + } + + private OpenUsdConnector Connector() + { + return new OpenUsdConnector(m_session.Object, m_sink, enableCommands: true); + } + + private void TargetTheVariable() + { + m_space.AddVariable(m_binding, "CommandTargetNodeId", new Variant(m_setpoint)); + } + + private void TargetTheMethod() + { + m_space.AddVariable(m_binding, "CommandMethodId", new Variant(m_method)); + } + + private void GrantWrite() + { + m_space.SetUserAccessLevel(m_setpoint, + new DataValue(new Variant((byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite)))); + } + + private void SetupWrite(StatusCode result) + { + m_session + .Setup(s => s.WriteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(new ValueTask(new WriteResponse + { + ResponseHeader = new ResponseHeader(), + Results = [result], + DiagnosticInfos = [] + })); + } + + [Test] + public async Task IssueCommandReturnsFalseWhenNoCommandBindingIsDiscoveredAsync() + { + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandReturnsFalseWhenTheConversionCannotBeInvertedAsync() + { + TargetTheVariable(); + m_space.AddVariable(m_binding, "Scale", new Variant(0.0)); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandRefusesToWriteWhenTheTargetHidesItsAccessLevelAsync() + { + TargetTheVariable(); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandRefusesToWriteWhenTheAccessLevelIsNotNumericAsync() + { + TargetTheVariable(); + m_space.SetUserAccessLevel(m_setpoint, new DataValue(new Variant("read-write"))); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandRefusesToWriteWhenCurrentWriteIsNotGrantedAsync() + { + TargetTheVariable(); + m_space.SetUserAccessLevel(m_setpoint, new DataValue(new Variant(AccessLevels.CurrentRead))); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandRefusesToWriteWhenTheReadOfTheEffectiveRightFaultsAsync() + { + TargetTheVariable(); + GrantWrite(); + OpenUsdConnector connector = Connector(); + m_space.FaultingAttributes.Add(Attributes.UserAccessLevel); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandRefusesToWriteWhenRolePermissionsWithholdWriteAsync() + { + TargetTheVariable(); + GrantWrite(); + m_space.SetUserRolePermissions(m_setpoint, new DataValue(new Variant(new ExtensionObject[] + { + new(new RolePermissionType + { + RoleId = Opc.Ua.ObjectIds.WellKnownRole_Observer, + Permissions = (uint)PermissionType.Read + }) + }))); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandWritesWhenRolePermissionsGrantWriteAsync() + { + TargetTheVariable(); + GrantWrite(); + m_space.SetUserRolePermissions(m_setpoint, new DataValue(new Variant(new ExtensionObject[] + { + new(new RolePermissionType + { + RoleId = Opc.Ua.ObjectIds.WellKnownRole_Operator, + Permissions = (uint)(PermissionType.Read | PermissionType.Write) + }) + }))); + SetupWrite(StatusCodes.Good); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(2.0, CancellationToken.None); + + Assert.That(issued, Is.True); + } + + [Test] + public async Task IssueCommandTreatsUndecodableRolePermissionsAsPermissiveAsync() + { + TargetTheVariable(); + GrantWrite(); + m_space.SetUserRolePermissions(m_setpoint, new DataValue(new Variant("not-a-permission-array"))); + SetupWrite(StatusCodes.Good); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(2.0, CancellationToken.None); + + Assert.That(issued, Is.True); + } + + [Test] + public async Task IssueCommandReturnsFalseWhenTheServerRejectsTheWriteAsync() + { + TargetTheVariable(); + GrantWrite(); + SetupWrite(StatusCodes.BadUserAccessDenied); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(2.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandAppliesTheInverseOffsetAndScaleAsync() + { + TargetTheVariable(); + GrantWrite(); + m_space.AddVariable(m_binding, "Scale", new Variant(2.0)); + m_space.AddVariable(m_binding, "Offset", new Variant(1.0)); + WriteValue? written = null; + m_session + .Setup(s => s.WriteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((RequestHeader _, ArrayOf values, CancellationToken _) => + { + written = values[0]; + return new ValueTask(new WriteResponse + { + ResponseHeader = new ResponseHeader(), + Results = [StatusCodes.Good], + DiagnosticInfos = [] + }); + }); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(5.0, CancellationToken.None); + + Assert.That(issued, Is.True); + Assert.That(written, Is.Not.Null); + Assert.That(written!.Value.WrappedValue.TryGetValue(out double actual), Is.True); + Assert.That(actual, Is.EqualTo(2.0).Within(1e-9)); + } + + [Test] + public async Task IssueCommandRefusesToCallWhenTheMethodHidesUserExecutableAsync() + { + TargetTheMethod(); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandRefusesToCallWhenUserExecutableIsFalseAsync() + { + TargetTheMethod(); + m_space.SetUserExecutable(m_method, new DataValue(new Variant(false))); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandRefusesToCallWhenTheReadOfUserExecutableFaultsAsync() + { + TargetTheMethod(); + m_space.SetUserExecutable(m_method, new DataValue(new Variant(true))); + OpenUsdConnector connector = Connector(); + m_space.FaultingAttributes.Add(Attributes.UserExecutable); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandCallsTheMethodOnItsAggregatingParentAsync() + { + TargetTheMethod(); + m_space.SetUserExecutable(m_method, new DataValue(new Variant(true))); + CallMethodRequest? called = null; + m_session + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((RequestHeader _, ArrayOf requests, CancellationToken _) => + { + called = requests[0]; + return new ValueTask(new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new CallMethodResult { StatusCode = StatusCodes.Good }], + DiagnosticInfos = [] + }); + }); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(3.0, CancellationToken.None); + + Assert.That(issued, Is.True); + Assert.That(called, Is.Not.Null); + Assert.That(called!.MethodId, Is.EqualTo(m_method)); + Assert.That(called.ObjectId.IsNull, Is.False); + } + + [Test] + public async Task IssueCommandReturnsFalseWhenTheCallFaultsAsync() + { + TargetTheMethod(); + m_space.SetUserExecutable(m_method, new DataValue(new Variant(true))); + m_session + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(new ServiceResultException(StatusCodes.BadUserAccessDenied)); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(3.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandUsesTheDeclaredCommandTargetAsTheMethodOwnerAsync() + { + TargetTheMethod(); + NodeId owner = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Owner"); + m_space.AddVariable(m_binding, "CommandTargetNodeId", new Variant(owner)); + m_space.SetUserExecutable(m_method, new DataValue(new Variant(true))); + CallMethodRequest? called = null; + m_session + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((RequestHeader _, ArrayOf requests, CancellationToken _) => + { + called = requests[0]; + return new ValueTask(new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new CallMethodResult { StatusCode = StatusCodes.Good }], + DiagnosticInfos = [] + }); + }); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(3.0, CancellationToken.None); + + Assert.That(issued, Is.True); + Assert.That(called!.ObjectId, Is.EqualTo(owner)); + } + + [Test] + public async Task IssueCommandIgnoresABindingSuppressedByItsEnabledTombstoneAsync() + { + TargetTheVariable(); + m_space.AddVariable(m_binding, "Enabled", new Variant(false)); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + [Test] + public async Task IssueCommandIgnoresAnObservableBindingAsync() + { + NodeId observable = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Ignored"); + Assert.That(observable.IsNull, Is.False); + m_space.SetValue(m_binding, new DataValue(new Variant(0))); + m_space.AddVariable(m_binding, "CommandTargetNodeId", new Variant(m_setpoint)); + m_space.SetValue(FindSignalRole(), new DataValue(new Variant(0))); + OpenUsdConnector connector = Connector(); + + bool issued = await connector.IssueCommandAsync(1.0, CancellationToken.None); + + Assert.That(issued, Is.False); + } + + private NodeId FindSignalRole() + { + BrowseResponse response = m_space.Browse( + [ + new BrowseDescription + { + NodeId = m_binding, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true + } + ]); + ArrayOf refs = response.Results[0].References; + for (int i = 0; i < refs.Count; i++) + { + if (refs[i].BrowseName.Name == "SignalRole") + { + return ExpandedNodeId.ToNodeId(refs[i].NodeId, m_space.NamespaceUris); + } + } + return NodeId.Null; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorConversionEdgeTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorConversionEdgeTests.cs new file mode 100644 index 0000000000..32e905f788 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorConversionEdgeTests.cs @@ -0,0 +1,252 @@ +/* ======================================================================== + * 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 NUnit.Framework; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Fail-closed edges of the ยง5.8 conversion profile and of the ยง5.2 stage-digest + /// guards that do not require a session. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorConversionEdgeTests + { + private static EUInformation Unit(string code) + { + int id = 0; + foreach (char c in code) + { + id = (id << 8) | (byte)c; + } + return new EUInformation { UnitId = id, DisplayName = new LocalizedText(code) }; + } + + private static OpenUsdConnector.BindingInfo Binding( + OpenUsdRenderTargetKind kind, string? source, string? target) + { + return new OpenUsdConnector.BindingInfo + { + Kind = kind, + PrimPath = "/World/Robot", + PropertyName = "value", + SourceEngineeringUnits = source == null ? null : Unit(source), + TargetEngineeringUnits = target == null ? null : Unit(target) + }; + } + + [Test] + public void ConvertLeavesDisplayColorUnresolvedForANonScalarSource() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.DisplayColor, null, null); + + Variant result = OpenUsdConnector.Convert(b, new Variant("warm")); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void ConvertLeavesEmissiveColorUnresolvedForANonScalarSource() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.EmissiveColor, null, null); + + Variant result = OpenUsdConnector.Convert(b, new Variant("bright")); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void ConvertLeavesTranslationUnresolvedForAnUnhonourableLengthUnit() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Translation, "MTR", "FOT"); + + Variant result = OpenUsdConnector.Convert(b, new Variant(1.0)); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void ConvertLeavesGeoreferenceHeightUnresolvedForAnUnhonourableLengthUnit() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Georeference, "MTR", "FOT"); + b.PropertyName = "cesium:anchor:height"; + + Variant result = OpenUsdConnector.Convert(b, new Variant(12.0)); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void ConvertLeavesGeoreferenceUnresolvedWhenTheTargetAttributeIsUnnamed() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Georeference, null, null); + b.PropertyName = string.Empty; + + Variant result = OpenUsdConnector.Convert(b, new Variant(12.0)); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void ConvertScalesRadiansToDegreesForAScalarTarget() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, "C81", "DD"); + + Variant result = OpenUsdConnector.Convert(b, new Variant(Math.PI)); + + Assert.That(result.TryGetValue(out double actual), Is.True); + Assert.That(actual, Is.EqualTo(180.0).Within(1e-9)); + } + + [Test] + public void ConvertScalesDegreesToRadiansForAScalarTarget() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, "DD", "C81"); + + Variant result = OpenUsdConnector.Convert(b, new Variant(180.0)); + + Assert.That(result.TryGetValue(out double actual), Is.True); + Assert.That(actual, Is.EqualTo(Math.PI).Within(1e-9)); + } + + [Test] + public void ConvertLeavesAScalarTargetUnresolvedForAnUnknownUnitPair() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, "MTR", "FOT"); + + Variant result = OpenUsdConnector.Convert(b, new Variant(1.0)); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void UnitCodeFallsBackToTheDisplayNameWhenNoUnitIdIsSet() + { + var units = new EUInformation { UnitId = 0, DisplayName = new LocalizedText("rad") }; + + string code = OpenUsdConnector.UnitCode(units); + + Assert.That(code, Is.EqualTo("rad")); + } + + [Test] + public void UnitCodeIsEmptyForAnUndeclaredUnit() + { + Assert.That(OpenUsdConnector.UnitCode(null), Is.Empty); + } + + [Test] + public void VerifyStageDigestRejectsARepresentationWithoutADigest() + { + var rep = new OpenUsdConnector.RepresentationInfo(); + + bool verified = OpenUsdConnector.VerifyStageDigest(rep, [1, 2, 3]); + + Assert.That(verified, Is.False); + } + + [Test] + public void VerifyStageDigestRejectsARepresentationWithoutADigestAlgorithm() + { + var rep = new OpenUsdConnector.RepresentationInfo + { + RootLayerDigest = new ByteString(new byte[] { 1, 2, 3 }), + DigestAlgorithm = OpenUsdDigestAlgorithm.None + }; + + bool verified = OpenUsdConnector.VerifyStageDigest(rep, [1, 2, 3]); + + Assert.That(verified, Is.False); + } + + [Test] + public void VerifyStageDigestRejectsNullContent() + { + var rep = new OpenUsdConnector.RepresentationInfo + { + RootLayerDigest = new ByteString(new byte[] { 1, 2, 3 }), + DigestAlgorithm = OpenUsdDigestAlgorithm.Sha256 + }; + + bool verified = OpenUsdConnector.VerifyStageDigest(rep, null!); + + Assert.That(verified, Is.False); + } + + [Test] + public void TryInvertConversionRejectsAZeroScale() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, null, null); + b.Scale = 0.0; + + bool inverted = OpenUsdConnector.TryInvertConversion(b, 1.0, out double uaValue); + + Assert.That(inverted, Is.False); + Assert.That(uaValue, Is.Zero); + } + + [Test] + public void TryInvertConversionRejectsAnUnhonourableUnitPair() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Custom, "MTR", "FOT"); + + bool inverted = OpenUsdConnector.TryInvertConversion(b, 1.0, out _); + + Assert.That(inverted, Is.False); + } + + [Test] + public void TryInvertConversionUndoesTheAngleFactorForARotationTarget() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation, "C81", "DD"); + + bool inverted = OpenUsdConnector.TryInvertConversion(b, 180.0, out double uaValue); + + Assert.That(inverted, Is.True); + Assert.That(uaValue, Is.EqualTo(Math.PI).Within(1e-9)); + } + + [Test] + public void SelectCommandBindingIgnoresABindingWithoutATargetOrMethod() + { + var rep = new OpenUsdConnector.RepresentationInfo(); + rep.Bindings.Add(new OpenUsdConnector.BindingInfo + { + Intent = OpenUsdIntentProfile.UsdToUaCommand, + SignalRole = OpenUsdSignalRole.Controllable + }); + + OpenUsdConnector.BindingInfo? selected = OpenUsdConnector.SelectCommandBinding([rep]); + + Assert.That(selected, Is.Null); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorConvertTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorConvertTests.cs new file mode 100644 index 0000000000..324b1728ff --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorConvertTests.cs @@ -0,0 +1,680 @@ +/* ======================================================================== + * 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 NUnit.Framework; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Unit tests for : the pure mapping from a + /// source to the USD-side value for each render-target kind. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorConvertTests + { + private static OpenUsdConnector.BindingInfo Binding( + OpenUsdRenderTargetKind kind, double scale = 1.0, double offset = 0.0) + { + return new() { Kind = kind, Scale = scale, Offset = offset }; + } + + [TestCase(OpenUsdRenderTargetKind.Translation)] + [TestCase(OpenUsdRenderTargetKind.Rotation)] + [TestCase(OpenUsdRenderTargetKind.Scale)] + [TestCase(OpenUsdRenderTargetKind.Opacity)] + [TestCase(OpenUsdRenderTargetKind.Custom)] + public void ScalarKindsApplyScaleAndOffset(OpenUsdRenderTargetKind kind) + { + OpenUsdConnector.BindingInfo b = Binding(kind, scale: 2.0, offset: 1.0); + + Variant result = OpenUsdConnector.Convert(b, new Variant(3.0)); + + Assert.That(result.TryGetValue(out double value), Is.True); + Assert.That(value, Is.EqualTo(7.0).Within(1e-9)); + } + + [Test] + public void IntegerSourceIsCoercedToDouble() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Translation, scale: 1.0, offset: 0.0); + + Variant result = OpenUsdConnector.Convert(b, new Variant(5)); + + Assert.That(result.TryGetValue(out double value), Is.True); + Assert.That(value, Is.EqualTo(5.0).Within(1e-9)); + } + + [TestCase(20.0, 0f, 0f, 1f)] + [TestCase(100.0, 1f, 0f, 0f)] + [TestCase(-50.0, 0f, 0f, 1f)] + [TestCase(500.0, 1f, 0f, 0f)] + public void DisplayColorMapsAndClamps(double raw, float r, float g, float bl) + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.DisplayColor); + + Variant result = OpenUsdConnector.Convert(b, new Variant(raw)); + + Assert.That(result.TryGetValue(out ArrayOf colour), Is.True); + Assert.That(colour.Count, Is.EqualTo(3)); + Assert.That(colour[0], Is.EqualTo(r).Within(1e-4)); + Assert.That(colour[1], Is.EqualTo(g).Within(1e-4)); + Assert.That(colour[2], Is.EqualTo(bl).Within(1e-4)); + } + + [Test] + public void EmissiveColorMapsPressure() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.EmissiveColor); + + Variant hot = OpenUsdConnector.Convert(b, new Variant(6.0)); + Assert.That(hot.TryGetValue(out ArrayOf hotColour), Is.True); + Assert.That(hotColour[0], Is.EqualTo(0.1f).Within(1e-4)); + Assert.That(hotColour[1], Is.EqualTo(1f).Within(1e-4)); + Assert.That(hotColour[2], Is.EqualTo(0.2f).Within(1e-4)); + + Variant cold = OpenUsdConnector.Convert(b, new Variant(0.0)); + Assert.That(cold.TryGetValue(out ArrayOf coldColour), Is.True); + Assert.That(coldColour[0], Is.EqualTo(0f).Within(1e-4)); + Assert.That(coldColour[1], Is.EqualTo(0f).Within(1e-4)); + Assert.That(coldColour[2], Is.EqualTo(0f).Within(1e-4)); + } + + [TestCase(1.0, "inherited")] + [TestCase(0.0, "invisible")] + public void VisibilityMapsToToken(double raw, string expected) + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Visibility); + + Variant result = OpenUsdConnector.Convert(b, new Variant(raw)); + + Assert.That(result.TryGetValue(out string token), Is.True); + Assert.That(token, Is.EqualTo(expected)); + } + + [Test] + public void NullSourceReturnsNullVariant() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Translation); + + Variant result = OpenUsdConnector.Convert(b, default); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void ThreeDCartesianCoordinatesTranslationReturnsDoubleArray() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Translation); + var coordinates = new ThreeDCartesianCoordinates { X = 1.0, Y = 2.0, Z = 3.0 }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(coordinates))); + + Assert.That(result.TryGetValue(out ArrayOf vector), Is.True); + Assert.That(vector.Count, Is.EqualTo(3)); + Assert.That(vector[0], Is.EqualTo(1.0).Within(1e-9)); + Assert.That(vector[1], Is.EqualTo(2.0).Within(1e-9)); + Assert.That(vector[2], Is.EqualTo(3.0).Within(1e-9)); + } + + [Test] + public void ThreeDCartesianCoordinatesTranslationAppliesScaleAndOffsetElementWise() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Translation, scale: 2.0, offset: 1.0); + var coordinates = new ThreeDCartesianCoordinates { X = 1.0, Y = 2.0, Z = 3.0 }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(coordinates))); + + Assert.That(result.TryGetValue(out ArrayOf vector), Is.True); + Assert.That(vector[0], Is.EqualTo(3.0).Within(1e-9)); + Assert.That(vector[1], Is.EqualTo(5.0).Within(1e-9)); + Assert.That(vector[2], Is.EqualTo(7.0).Within(1e-9)); + } + + [Test] + public void ThreeDOrientationRotationAppliesNoUnitConversionWhenUnitsAreUndeclared() + { + // H6/ยง5.8: the angle factor is step (1) of the conversion, but it is only + // applied when the binding actually declares an AngleUnit. With no declared + // units the source value is authored unchanged โ€” this preserves a server + // that already publishes degrees. + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation); + var orientation = new ThreeDOrientation { A = 0.1, B = 0.2, C = 0.3 }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(orientation))); + + Assert.That(result.TryGetValue(out ArrayOf vector), Is.True); + Assert.That(vector.Count, Is.EqualTo(3)); + Assert.That(vector[0], Is.EqualTo(0.1).Within(1e-9)); + Assert.That(vector[1], Is.EqualTo(0.2).Within(1e-9)); + Assert.That(vector[2], Is.EqualTo(0.3).Within(1e-9)); + } + + /// + /// UNECE common code "C81" (radian) packed into an EUInformation UnitId. + /// + private static EUInformation Radians() + { + return new EUInformation + { + NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact", + UnitId = ('C' << 16) | ('8' << 8) | '1', + DisplayName = new LocalizedText("rad") + }; + } + + /// + /// UNECE common code "DD" (degree) packed into an EUInformation UnitId. + /// + private static EUInformation Degrees() + { + return new EUInformation + { + NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact", + UnitId = ('D' << 8) | 'D', + DisplayName = new LocalizedText("ยฐ") + }; + } + + [Test] + public void RadianSourceIsConvertedToDegreesForRotationTargets() + { + // ยง5.8: "USD rotation ops are in degrees; convert an AngleUnit of radians + // with x 180/pi". + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation); + b.SourceEngineeringUnits = Radians(); + + Variant result = OpenUsdConnector.Convert(b, new Variant(0.1)); + + Assert.That(result.TryGetValue(out double degrees), Is.True); + Assert.That(degrees, Is.EqualTo(5.729577951308232).Within(1e-9)); + } + + [Test] + public void RadianSourceIsConvertedElementWiseForStructuredRotation() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation); + b.SourceEngineeringUnits = Radians(); + b.TargetEngineeringUnits = Degrees(); + var orientation = new ThreeDOrientation { A = 0.1, B = 0.2, C = 0.3 }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(orientation))); + + Assert.That(result.TryGetValue(out ArrayOf vector), Is.True); + Assert.That(vector[0], Is.EqualTo(5.729577951308232).Within(1e-9)); + Assert.That(vector[1], Is.EqualTo(11.459155902616464).Within(1e-9)); + Assert.That(vector[2], Is.EqualTo(17.188733853924695).Within(1e-9)); + } + + [Test] + public void DeclaredDegreeSourceIsNotConverted() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation); + b.SourceEngineeringUnits = Degrees(); + + Variant result = OpenUsdConnector.Convert(b, new Variant(90.0)); + + Assert.That(result.TryGetValue(out double degrees), Is.True); + Assert.That(degrees, Is.EqualTo(90.0).Within(1e-9)); + } + + [Test] + public void UnhonourableAngleUnitFailsClosed() + { + // A declared AngleUnit the connector has no factor for must leave the target + // unresolved rather than author the raw value 1:1. + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation); + b.SourceEngineeringUnits = new EUInformation + { + UnitId = ('M' << 16) | ('I' << 8) | 'K', + DisplayName = new LocalizedText("mil") + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(0.1)); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void TransformKindFailsClosedForStructuredSource() + { + // C1/ยง5.8: the matrix4d/quaternion transform profile is not implemented, so + // the target is left unresolved (no update) rather than fabricated as 0.0. + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Transform); + var frame = new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates { X = 1.0, Y = 2.0, Z = 3.0 }, + Orientation = new ThreeDOrientation { A = 0.0, B = 0.0, C = 0.0 } + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(frame))); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void TransformKindFailsClosedForScalarSource() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Transform); + + Variant result = OpenUsdConnector.Convert(b, new Variant(1.0)); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void UnknownRenderTargetKindFailsClosed() + { + // C1: a RenderTargetKind introduced by a later revision of the companion + // specification is never guessed at โ€” no value is authored. + OpenUsdConnector.BindingInfo b = Binding((OpenUsdRenderTargetKind)99); + + Variant result = OpenUsdConnector.Convert(b, new Variant(3.0)); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void UndecodableStructuredSourceFailsClosedInsteadOfAuthoringOffset() + { + // C1: a structured source ToDouble cannot decode used to fall through to a + // fabricated 0.0, so the connector authored Offset. It must now be unresolved. + OpenUsdConnector.BindingInfo b = Binding( + OpenUsdRenderTargetKind.Opacity, scale: 1.0, offset: 0.25); + var frame = new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates { X = 1.0, Y = 2.0, Z = 3.0 }, + Orientation = new ThreeDOrientation { A = 0.0, B = 0.0, C = 0.0 } + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(frame))); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void GeoreferenceLatitudeComponentIsAuthoredInDegrees() + { + // ยง5.8 geospatial profile: latitude/longitude are authored as decimal + // degrees on the target anchor component, never as a raw xformOp. + var b = new OpenUsdConnector.BindingInfo + { + Kind = OpenUsdRenderTargetKind.Georeference, + Scale = 1.0, + Offset = 0.0, + PropertyName = "cesium:anchor:latitude" + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(47.3769)); + + Assert.That(result.TryGetValue(out double latitude), Is.True); + Assert.That(latitude, Is.EqualTo(47.3769).Within(1e-9)); + } + + [Test] + public void GeoreferenceHeightComponentAppliesScaleAndOffset() + { + var b = new OpenUsdConnector.BindingInfo + { + Kind = OpenUsdRenderTargetKind.Georeference, + Scale = 0.001, + Offset = 5.0, + PropertyName = "cesium:anchor:height" + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(408000.0)); + + Assert.That(result.TryGetValue(out double height), Is.True); + Assert.That(height, Is.EqualTo(413.0).Within(1e-9)); + } + + [Test] + public void GeoreferenceWithUnknownComponentFailsClosed() + { + // The single most dangerous fabrication the audit found: an unresolvable + // georeference target used to author Offset (usually 0.0), teleporting the + // asset to lat/lon 0,0. ยง5.8 requires it to stay unresolved. + var b = new OpenUsdConnector.BindingInfo + { + Kind = OpenUsdRenderTargetKind.Georeference, + Scale = 1.0, + Offset = 0.0, + PropertyName = "xformOp:translate" + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(47.3769)); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void GeoreferenceWithStructuredSourceFailsClosed() + { + // ยง5.8: "an unmapped or unsupported CRS shall leave the target unresolved + // (no update) rather than author an unprojected value." + var b = new OpenUsdConnector.BindingInfo + { + Kind = OpenUsdRenderTargetKind.Georeference, + Scale = 1.0, + Offset = 0.0, + PropertyName = "cesium:anchor:latitude" + }; + var frame = new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates { X = 1.0, Y = 2.0, Z = 3.0 }, + Orientation = new ThreeDOrientation { A = 0.0, B = 0.0, C = 0.0 } + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(frame))); + + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void ClientRenderTargetKindMirrorsGeoreference() + { + // The hand-maintained client mirror must track DataType i=3002 in the + // NodeSet, where Georeference = 9 was added in Release 0.3.0. + Assert.That((int)OpenUsdRenderTargetKind.Georeference, Is.EqualTo(9)); + } + + [TestCase(1.0, 0.0, 42.5, 42.5)] + [TestCase(2.0, 1.0, 7.0, 3.0)] + [TestCase(0.5, -1.0, 2.0, 6.0)] + public void InverseConversionUndoesScaleAndOffset( + double scale, double offset, double usdValue, double expected) + { + // ยง5.10: the trigger value is converted back through the inverse of ยง5.8. + OpenUsdConnector.BindingInfo b = Binding( + OpenUsdRenderTargetKind.Custom, scale: scale, offset: offset); + + Assert.That(OpenUsdConnector.TryInvertConversion(b, usdValue, out double uaValue), Is.True); + Assert.That(uaValue, Is.EqualTo(expected).Within(1e-9)); + } + + [Test] + public void InverseConversionUndoesTheAngleUnitFactor() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation); + b.SourceEngineeringUnits = Radians(); + + Assert.That( + OpenUsdConnector.TryInvertConversion(b, 5.729577951308232, out double radians), Is.True); + Assert.That(radians, Is.EqualTo(0.1).Within(1e-9)); + } + + [Test] + public void InverseConversionRefusesAZeroScale() + { + OpenUsdConnector.BindingInfo b = Binding( + OpenUsdRenderTargetKind.Custom, scale: 0.0, offset: 1.0); + + Assert.That(OpenUsdConnector.TryInvertConversion(b, 5.0, out _), Is.False); + } + + [Test] + public void InverseConversionRefusesAnUnhonourableUnit() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation); + b.SourceEngineeringUnits = new EUInformation + { + UnitId = ('M' << 16) | ('I' << 8) | 'K', + DisplayName = new LocalizedText("mil") + }; + + Assert.That(OpenUsdConnector.TryInvertConversion(b, 5.0, out _), Is.False); + } + + [Test] + public void DisabledCommandBindingIsNotSelectedForActuation() + { + // ยง5.4: "Enabled | Boolean | M | false is a tombstone suppressing an + // inherited binding." A suppressed command binding must never be actuated. + var rep = new OpenUsdConnector.RepresentationInfo { PrimPath = "/Plant/Pumps/P101" }; + rep.Bindings.Add(new OpenUsdConnector.BindingInfo + { + Intent = OpenUsdIntentProfile.UsdToUaCommand, + SignalRole = OpenUsdSignalRole.Controllable, + CommandTargetNodeId = new NodeId(9999u, 3), + Enabled = false + }); + + Assert.That(OpenUsdConnector.SelectCommandBinding([rep]), Is.Null); + } + + [Test] + public void EnabledCommandBindingIsSelectedForActuation() + { + var rep = new OpenUsdConnector.RepresentationInfo { PrimPath = "/Plant/Pumps/P101" }; + rep.Bindings.Add(new OpenUsdConnector.BindingInfo + { + Intent = OpenUsdIntentProfile.UsdToUaCommand, + SignalRole = OpenUsdSignalRole.Controllable, + CommandTargetNodeId = new NodeId(9999u, 3) + }); + + Assert.That(OpenUsdConnector.SelectCommandBinding([rep]), Is.Not.Null); + } + + [Test] + public void DisabledCommandBindingDoesNotShadowAnEnabledOne() + { + var rep = new OpenUsdConnector.RepresentationInfo { PrimPath = "/Plant/Pumps/P101" }; + rep.Bindings.Add(new OpenUsdConnector.BindingInfo + { + Intent = OpenUsdIntentProfile.UsdToUaCommand, + SignalRole = OpenUsdSignalRole.Controllable, + CommandTargetNodeId = new NodeId(1u, 3), + Enabled = false + }); + rep.Bindings.Add(new OpenUsdConnector.BindingInfo + { + Intent = OpenUsdIntentProfile.UsdToUaCommand, + SignalRole = OpenUsdSignalRole.Controllable, + CommandTargetNodeId = new NodeId(2u, 3) + }); + + OpenUsdConnector.BindingInfo? selected = OpenUsdConnector.SelectCommandBinding([rep]); + Assert.That(selected, Is.Not.Null); + Assert.That(selected!.CommandTargetNodeId, Is.EqualTo(new NodeId(2u, 3))); + } + + [Test] + public void ObservableCommandBindingIsNotSelectedForActuation() + { + // ยง5.9: only a Controllable binding may be actuated. + var rep = new OpenUsdConnector.RepresentationInfo { PrimPath = "/Plant/Pumps/P101" }; + rep.Bindings.Add(new OpenUsdConnector.BindingInfo + { + Intent = OpenUsdIntentProfile.UsdToUaCommand, + SignalRole = OpenUsdSignalRole.Observable, + CommandTargetNodeId = new NodeId(9999u, 3) + }); + + Assert.That(OpenUsdConnector.SelectCommandBinding([rep]), Is.Null); + } + + [Test] + public void CommandBindingWithOnlyAMethodIdIsSelectable() + { + // ยง5.10: "If CommandMethodId is present, the connector Calls that Method โ€ฆ; + // otherwise it Writes the converted value to CommandTargetNodeId." + var rep = new OpenUsdConnector.RepresentationInfo { PrimPath = "/Plant/Pumps/P101" }; + rep.Bindings.Add(new OpenUsdConnector.BindingInfo + { + Intent = OpenUsdIntentProfile.UsdToUaCommand, + SignalRole = OpenUsdSignalRole.Controllable, + CommandMethodId = new NodeId(4242u, 3) + }); + + OpenUsdConnector.BindingInfo? selected = OpenUsdConnector.SelectCommandBinding([rep]); + Assert.That(selected, Is.Not.Null); + Assert.That(selected!.CommandMethodId, Is.EqualTo(new NodeId(4242u, 3))); + } + + [TestCase("/Plant/Pumps/P101", "Impeller", "/Plant/Pumps/P101/Impeller")] + [TestCase("/Plant/Pumps/P101", "Body/Impeller", "/Plant/Pumps/P101/Body/Impeller")] + [TestCase("/Plant/Pumps/P101", "", "/Plant/Pumps/P101")] + [TestCase("/Plant/Pumps/P101", "/Absolute/Prim", "/Absolute/Prim")] + [TestCase("", "Impeller", "/Impeller")] + public void RelativeTargetPrimPathIsJoinedToTheRepresentationPrimPath( + string basePath, string target, string expected) + { + // ยง5.7: "a relative path shall be joined to the representation PrimPath, + // never authored at the layer root." + Assert.That(OpenUsdConnector.JoinPrimPath(basePath, target), Is.EqualTo(expected)); + } + + [Test] + public void ThreeDOrientationRotationAppliesScaleAndOffsetElementWise() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation, scale: 10.0, offset: 1.0); + var orientation = new ThreeDOrientation { A = 1.0, B = 2.0, C = 3.0 }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(orientation))); + + Assert.That(result.TryGetValue(out ArrayOf vector), Is.True); + Assert.That(vector[0], Is.EqualTo(11.0).Within(1e-9)); + Assert.That(vector[1], Is.EqualTo(21.0).Within(1e-9)); + Assert.That(vector[2], Is.EqualTo(31.0).Within(1e-9)); + } + + [Test] + public void ThreeDFrameTranslationReturnsCartesianCoordinatesArray() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Translation); + var frame = new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates { X = 4.0, Y = 5.0, Z = 6.0 }, + Orientation = new ThreeDOrientation { A = 0.0, B = 0.0, C = 0.0 } + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(frame))); + + Assert.That(result.TryGetValue(out ArrayOf vector), Is.True); + Assert.That(vector[0], Is.EqualTo(4.0).Within(1e-9)); + Assert.That(vector[1], Is.EqualTo(5.0).Within(1e-9)); + Assert.That(vector[2], Is.EqualTo(6.0).Within(1e-9)); + } + + [Test] + public void ThreeDFrameRotationReturnsOrientationArray() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Rotation); + var frame = new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates { X = 0.0, Y = 0.0, Z = 0.0 }, + Orientation = new ThreeDOrientation { A = 7.0, B = 8.0, C = 9.0 } + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(frame))); + + Assert.That(result.TryGetValue(out ArrayOf vector), Is.True); + Assert.That(vector[0], Is.EqualTo(7.0).Within(1e-9)); + Assert.That(vector[1], Is.EqualTo(8.0).Within(1e-9)); + Assert.That(vector[2], Is.EqualTo(9.0).Within(1e-9)); + } + + [Test] + public void ThreeDFrameTranslationAppliesScaleAndOffsetElementWise() + { + OpenUsdConnector.BindingInfo b = Binding(OpenUsdRenderTargetKind.Translation, scale: 0.5, offset: -1.0); + var frame = new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates { X = 2.0, Y = 4.0, Z = 6.0 }, + Orientation = new ThreeDOrientation { A = 0.0, B = 0.0, C = 0.0 } + }; + + Variant result = OpenUsdConnector.Convert(b, new Variant(new ExtensionObject(frame))); + + Assert.That(result.TryGetValue(out ArrayOf vector), Is.True); + Assert.That(vector[0], Is.Zero); + Assert.That(vector[1], Is.EqualTo(1.0).Within(1e-9)); + Assert.That(vector[2], Is.EqualTo(2.0).Within(1e-9)); + } + + // --------------------------------------------------------------------- + // H3 โ€” ยง5.15: "verif[y] each digest" / "shall not silently mix unverified + // delivered bytes into the stage". An asset delivered *without* a digest is + // unverifiable and must never be reported as verified. + // --------------------------------------------------------------------- + + [Test] + public void DeliveredAssetWithoutDigestIsRefusedWhenDigestsAreRequired() + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes("#usda 1.0\n"); + + Assert.That( + () => OpenUsdConnector.VerifyDeliveredAsset( + "orphan.usda", bytes, default, OpenUsdDigestAlgorithm.Sha256, requireDigests: true), + Throws.InstanceOf()); + } + + [Test] + public void DeliveredAssetWithoutDigestIsNotReportedVerified() + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes("#usda 1.0\n"); + + bool verified = OpenUsdConnector.VerifyDeliveredAsset( + "orphan.usda", bytes, default, OpenUsdDigestAlgorithm.Sha256, requireDigests: false); + + Assert.That(verified, Is.False); + } + + [Test] + public void DeliveredAssetWithMatchingDigestIsVerified() + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes("#usda 1.0\n"); + byte[] digest; + using (System.Security.Cryptography.SHA256 sha = System.Security.Cryptography.SHA256.Create()) + { +#pragma warning disable CA1850 + digest = sha.ComputeHash(bytes); +#pragma warning restore CA1850 + } + + bool verified = OpenUsdConnector.VerifyDeliveredAsset( + "root.usda", bytes, new ByteString(digest), OpenUsdDigestAlgorithm.Sha256, requireDigests: true); + + Assert.That(verified, Is.True); + } + + [Test] + public void DeliveredAssetWithMismatchedDigestIsRefused() + { + byte[] bytes = System.Text.Encoding.UTF8.GetBytes("#usda 1.0\n"); + var wrong = new ByteString(new byte[32]); + + Assert.That( + () => OpenUsdConnector.VerifyDeliveredAsset( + "root.usda", bytes, wrong, OpenUsdDigestAlgorithm.Sha256, requireDigests: true), + Throws.InstanceOf()); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorDiscoveryTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorDiscoveryTests.cs new file mode 100644 index 0000000000..4dbcb49686 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorDiscoveryTests.cs @@ -0,0 +1,419 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Client; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Drives discovery and ยง5.7 binding-source + /// resolution against an in-memory address space. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorDiscoveryTests + { + private FakeAddressSpace m_space = null!; + private Mock m_session = null!; + private MockUsdSink m_sink = null!; + + [SetUp] + public void SetUp() + { + m_space = new FakeAddressSpace(); + m_session = FakeSession.Create(m_space); + m_sink = new MockUsdSink(); + } + + private OpenUsdConnector Connector() + { + return new OpenUsdConnector(m_session.Object, m_sink); + } + + private NodeId AddFacility() + { + return m_space.AddObject(Opc.Ua.ObjectIds.Server, "OpenUSD", + browseNameNamespace: m_space.OpenUsdNamespaceIndex); + } + + private NodeId AddRegistry() + { + return m_space.AddObject(AddFacility(), "Representations"); + } + + private NodeId RepresentationTypeId + => new(OpenUsdModel.RepresentationTypeId, m_space.OpenUsdNamespaceIndex); + + private NodeId ValueChangeBindingTypeId + => new(OpenUsdModel.ValueChangeBindingTypeId, m_space.OpenUsdNamespaceIndex); + + private NodeId ComponentBindingTypeId + => new(OpenUsdModel.ComponentBindingTypeId, m_space.OpenUsdNamespaceIndex); + + [Test] + public async Task DiscoverRepresentationReturnsNullWhenTheFacilityHasNoRegistryAsync() + { + AddFacility(); + OpenUsdConnector connector = Connector(); + + OpenUsdConnector.RepresentationInfo? info = + await connector.DiscoverRepresentationAsync(CancellationToken.None); + + Assert.That(info, Is.Null); + } + + [Test] + public async Task DiscoverAllRepresentationsReturnsEmptyWhenTheFacilityHasNoRegistryAsync() + { + AddFacility(); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps, Is.Empty); + } + + [Test] + public async Task DiscoverRepresentationFallsBackToTheConventionalRootWhenNotOrganizedAsync() + { + OpenUsdConnector connector = Connector(); + + OpenUsdConnector.RepresentationInfo? info = + await connector.DiscoverRepresentationAsync(CancellationToken.None); + + Assert.That(info, Is.Null); + Assert.That(m_space.BrowseCount, Is.GreaterThan(0)); + } + + [Test] + public async Task DiscoverRepresentationIgnoresRegistryChildrenOfAForeignTypeAsync() + { + NodeId registry = AddRegistry(); + m_space.AddObject(registry, "NotARepresentation", new NodeId(9999)); + OpenUsdConnector connector = Connector(); + + OpenUsdConnector.RepresentationInfo? info = + await connector.DiscoverRepresentationAsync(CancellationToken.None); + + Assert.That(info, Is.Null); + } + + [Test] + public async Task DiscoverAllRepresentationsReadsStageMetadataAsync() + { + NodeId registry = AddRegistry(); + NodeId rep = m_space.AddObject(registry, "Robot", RepresentationTypeId); + NodeId stage = m_space.AddObject(rep, "Stage-Object"); + m_space.AddVariable(rep, "PrimPath", new Variant("/World/Robot")); + m_space.AddVariable(rep, "Stage", new Variant(stage)); + m_space.AddVariable(stage, "RootLayerIdentifier", new Variant("robot.usda")); + m_space.AddVariable(stage, "RootLayerDigest", new Variant(new ByteString(new byte[] { 1, 2, 3 }))); + m_space.AddVariable(stage, "RootLayerDigestAlgorithm", new Variant(1)); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps, Has.Count.EqualTo(1)); + Assert.That(reps[0].PrimPath, Is.EqualTo("/World/Robot")); + Assert.That(reps[0].StageNodeId, Is.EqualTo(stage)); + Assert.That(reps[0].RootLayerIdentifier, Is.EqualTo("robot.usda")); + Assert.That(reps[0].RootLayerDigest.Length, Is.EqualTo(3)); + Assert.That(reps[0].DigestAlgorithm, Is.EqualTo(OpenUsdDigestAlgorithm.Sha256)); + } + + [Test] + public async Task DiscoverAllRepresentationsReadsComponentBindingsAsync() + { + NodeId registry = AddRegistry(); + NodeId rep = m_space.AddObject(registry, "Line", RepresentationTypeId); + m_space.AddVariable(rep, "PrimPath", new Variant("/World/Line")); + NodeId component = m_space.AddObject(rep, "Cells", ComponentBindingTypeId); + m_space.AddVariable(component, "Cardinality", new Variant(1)); + m_space.AddVariable(component, "CompositionArc", new Variant(2)); + m_space.AddVariable(component, "TargetPrimPath", new Variant("/World/Line/Cells")); + m_space.AddVariable(component, "ComponentAssetReference", new Variant("cell.usda")); + m_space.AddVariable(component, "Dynamic", new Variant(true)); + m_space.AddVariable(component, "Enabled", new Variant(false)); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps, Has.Count.EqualTo(1)); + Assert.That(reps[0].Components, Has.Count.EqualTo(1)); + OpenUsdConnector.ComponentInfo c = reps[0].Components[0]; + Assert.That(c.Cardinality, Is.EqualTo(OpenUsdCardinality.Many)); + Assert.That(c.Arc, Is.EqualTo(OpenUsdCompositionArc.Payload)); + Assert.That(c.TargetPrimPath, Is.EqualTo("/World/Line/Cells")); + Assert.That(c.ComponentAssetReference, Is.EqualTo("cell.usda")); + Assert.That(c.Dynamic, Is.True); + Assert.That(c.Enabled, Is.False); + } + + [Test] + public async Task DiscoverAllRepresentationsResolvesTheBindingSourceBySemanticIdAsync() + { + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + NodeId speed = m_space.AddVariable(machine, "Speed", new Variant(1.0)); + m_space.AddReference(speed, ReferenceTypeIds.HasDictionaryEntry, m_space.NewNodeId(), "SEM-SPEED"); + NodeId rep = AddRepresentationWithBinding(machine, out NodeId binding); + m_space.AddVariable(binding, "SourceSemanticId", new Variant("SEM-SPEED")); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(rep.IsNull, Is.False); + Assert.That(reps, Has.Count.EqualTo(1)); + Assert.That(reps[0].Bindings, Has.Count.EqualTo(1)); + Assert.That(reps[0].Bindings[0].SourceNodeId, Is.EqualTo(speed)); + } + + [Test] + public async Task DiscoverAllRepresentationsLeavesTheBindingUnresolvedWhenNoSemanticIdMatchesAsync() + { + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + NodeId speed = m_space.AddVariable(machine, "Speed", new Variant(1.0)); + m_space.AddReference(speed, ReferenceTypeIds.HasDictionaryEntry, m_space.NewNodeId(), "SEM-OTHER"); + AddRepresentationWithBinding(machine, out NodeId binding); + m_space.AddVariable(binding, "SourceSemanticId", new Variant("SEM-SPEED")); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps[0].Bindings[0].SourceNodeId.IsNull, Is.True); + } + + [Test] + public void DiscoverAllRepresentationsThrowsWhenTheSemanticIdMatchesMoreThanOneVariable() + { + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + NodeId first = m_space.AddVariable(machine, "SpeedA", new Variant(1.0)); + NodeId second = m_space.AddVariable(machine, "SpeedB", new Variant(2.0)); + m_space.AddReference(first, ReferenceTypeIds.HasDictionaryEntry, m_space.NewNodeId(), "SEM-SPEED"); + m_space.AddReference(second, ReferenceTypeIds.HasDictionaryEntry, m_space.NewNodeId(), "SEM-SPEED"); + AddRepresentationWithBinding(machine, out NodeId binding); + m_space.AddVariable(binding, "SourceSemanticId", new Variant("SEM-SPEED")); + OpenUsdConnector connector = Connector(); + + ServiceResultException? ex = Assert.ThrowsAsync( + () => connector.DiscoverAllRepresentationsAsync(CancellationToken.None)); + + Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadTooManyMatches)); + } + + [Test] + public async Task DiscoverAllRepresentationsSkipsResolutionWhenTheRepresentationHasNoAggregatingParentAsync() + { + NodeId registry = AddRegistry(); + NodeId rep = m_space.AddObject(registry, "Robot", RepresentationTypeId); + m_space.AddVariable(rep, "PrimPath", new Variant("/World/Robot")); + NodeId binding = m_space.AddObject(rep, "Speed", ValueChangeBindingTypeId); + m_space.AddVariable(binding, "SourceSemanticId", new Variant("SEM-SPEED")); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps[0].Bindings[0].SourceNodeId.IsNull, Is.True); + } + + [Test] + public async Task DiscoverAllRepresentationsResolvesTheBindingSourceByBrowsePathAsync() + { + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + NodeId speed = m_space.AddVariable(machine, "Speed", new Variant(1.0)); + AddRepresentationWithBinding(machine, out NodeId binding); + AddSourceBrowsePath(binding); + SetupTranslate(StatusCodes.Good, new BrowsePathTarget + { + TargetId = new ExpandedNodeId(speed), + RemainingPathIndex = uint.MaxValue + }); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps[0].Bindings[0].SourceNodeId, Is.EqualTo(speed)); + } + + [Test] + public async Task DiscoverAllRepresentationsIgnoresBrowsePathTargetsWithARemainingPathIndexAsync() + { + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + NodeId speed = m_space.AddVariable(machine, "Speed", new Variant(1.0)); + AddRepresentationWithBinding(machine, out NodeId binding); + AddSourceBrowsePath(binding); + SetupTranslate(StatusCodes.Good, new BrowsePathTarget + { + TargetId = new ExpandedNodeId(speed), + RemainingPathIndex = 0 + }); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps[0].Bindings[0].SourceNodeId.IsNull, Is.True); + } + + [Test] + public async Task DiscoverAllRepresentationsIgnoresBrowsePathTargetsThatAreNotVariablesAsync() + { + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + NodeId folder = m_space.AddObject(machine, "Folder"); + AddRepresentationWithBinding(machine, out NodeId binding); + AddSourceBrowsePath(binding); + SetupTranslate(StatusCodes.Good, new BrowsePathTarget + { + TargetId = new ExpandedNodeId(folder), + RemainingPathIndex = uint.MaxValue + }); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps[0].Bindings[0].SourceNodeId.IsNull, Is.True); + } + + [Test] + public async Task DiscoverAllRepresentationsIgnoresATargetThatIsUnknownToTheServerAsync() + { + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + AddRepresentationWithBinding(machine, out NodeId binding); + AddSourceBrowsePath(binding); + SetupTranslate(StatusCodes.Good, new BrowsePathTarget + { + TargetId = new ExpandedNodeId(new NodeId(424242u)), + RemainingPathIndex = uint.MaxValue + }); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps[0].Bindings[0].SourceNodeId.IsNull, Is.True); + } + + [Test] + public async Task DiscoverAllRepresentationsIgnoresABadBrowsePathTranslationAsync() + { + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + AddRepresentationWithBinding(machine, out NodeId binding); + AddSourceBrowsePath(binding); + SetupTranslate(StatusCodes.BadNoMatch); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps[0].Bindings[0].SourceNodeId.IsNull, Is.True); + } + + [Test] + public async Task DiscoverAllRepresentationsIgnoresAFailedBrowsePathTranslationAsync() + { + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + AddRepresentationWithBinding(machine, out NodeId binding); + AddSourceBrowsePath(binding); + m_session + .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(new ServiceResultException(StatusCodes.BadServiceUnsupported)); + OpenUsdConnector connector = Connector(); + + List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None); + + Assert.That(reps[0].Bindings[0].SourceNodeId.IsNull, Is.True); + } + + [Test] + public void StartThrowsWhenNoRepresentationIsDiscovered() + { + OpenUsdConnector connector = Connector(); + + Assert.ThrowsAsync( + () => connector.StartAsync(CancellationToken.None)); + } + + private NodeId AddRepresentationWithBinding(NodeId representedObject, out NodeId binding) + { + NodeId registry = AddRegistry(); + NodeId rep = m_space.AddObject(registry, "Robot", RepresentationTypeId); + m_space.AddObject(representedObject, "Robot", RepresentationTypeId, + ReferenceTypeIds.HasComponent, 0, rep); + m_space.AddVariable(rep, "PrimPath", new Variant("/World/Robot")); + binding = m_space.AddObject(rep, "SpeedBinding", ValueChangeBindingTypeId); + m_space.AddVariable(binding, "TargetPropertyName", new Variant("speed")); + return rep; + } + + private void AddSourceBrowsePath(NodeId binding) + { + var path = new RelativePath + { + Elements = + [ + new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName("Speed") + } + ] + }; + m_space.AddVariable(binding, "SourceBrowsePath", new Variant(new ExtensionObject(path))); + } + + private void SetupTranslate(StatusCode statusCode, params BrowsePathTarget[] targets) + { + m_session + .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(new ValueTask( + FakeSession.TranslateResponse(statusCode, targets))); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorFactoryAndDiTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorFactoryAndDiTests.cs new file mode 100644 index 0000000000..cf1b340354 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorFactoryAndDiTests.cs @@ -0,0 +1,188 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Opc.Ua.Client; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Unit tests for . + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorFactoryTests + { + private static Mock Telemetry() + { + var telemetry = new Mock(); + telemetry.SetupGet(t => t.LoggerFactory).Returns(NullLoggerFactory.Instance); + return telemetry; + } + + private static Mock Session() + { + var session = new Mock(); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + return session; + } + + [Test] + public void CtorNullTelemetryThrows() + { + Assert.Throws(() => new OpenUsdConnectorFactory(null!)); + } + + [Test] + public void CtorNullDefaultOptionsThrows() + { + Assert.Throws( + () => new OpenUsdConnectorFactory(Telemetry().Object, null!)); + } + + [Test] + public void CreateReturnsConnector() + { + var factory = new OpenUsdConnectorFactory(Telemetry().Object); + + OpenUsdConnector connector = factory.Create(Session().Object, new MockUsdSink()); + + Assert.That(connector, Is.Not.Null); + } + + [Test] + public void CreateNullSessionThrows() + { + var factory = new OpenUsdConnectorFactory(Telemetry().Object); + + Assert.Throws(() => factory.Create(null!, new MockUsdSink())); + } + } + + /// + /// Unit tests for construction and fail-closed + /// command actuation that do not require a live server. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorConstructionTests + { + private static Mock Session() + { + var session = new Mock(); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + return session; + } + + [Test] + public void CtorNullSessionThrows() + { + Assert.Throws(() => new OpenUsdConnector(null!, new MockUsdSink())); + } + + [Test] + public void CtorNullSinkThrows() + { + Assert.Throws(() => new OpenUsdConnector(Session().Object, null!)); + } + + [Test] + public void IssueCommandIsFailClosedByDefault() + { + var connector = new OpenUsdConnector(Session().Object, new MockUsdSink()); + + Assert.ThrowsAsync( + () => connector.IssueCommandAsync(1.0, CancellationToken.None)); + } + } + + /// + /// Unit tests for the AddOpenUsdConnector(...) dependency-injection extensions. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorDiTests + { + [Test] + public void AddOpenUsdConnectorRegistersFactoryAndOptions() + { + var services = new ServiceCollection(); + services.AddOpenUsdConnector(o => o.EnableCommands = true); + + using ServiceProvider provider = services.BuildServiceProvider(); + + var factory = provider.GetService(); + var options = provider.GetService(); + + Assert.That(factory, Is.Not.Null); + Assert.That(options, Is.Not.Null); + Assert.That(options!.EnableCommands, Is.True); + } + + [Test] + public void AddOpenUsdConnectorNullServicesThrows() + { + IServiceCollection services = null!; + + Assert.Throws(() => services.AddOpenUsdConnector()); + } + + [Test] + public void AddOpenUsdConnectorOnOpcUaBuilderRegistersFactory() + { + IOpcUaBuilder builder = new ServiceCollection().AddOpcUa(); + + using ServiceProvider provider = builder.AddOpenUsdConnector().Services.BuildServiceProvider(); + + Assert.That(provider.GetService(), Is.Not.Null); + } + + [Test] + public void AddOpenUsdConnectorNullBuilderThrows() + { + IOpcUaBuilder builder = null!; + + Assert.Throws(() => builder.AddOpenUsdConnector()); + } + + [Test] + public void AddOpenUsdConnectorNullClientBuilderThrows() + { + IOpcUaClientBuilder builder = null!; + + Assert.Throws(() => builder.AddOpenUsdConnector()); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorHistoryTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorHistoryTests.cs new file mode 100644 index 0000000000..14a3fb15e8 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdConnectorHistoryTests.cs @@ -0,0 +1,344 @@ +/* ======================================================================== + * 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 Moq; +using NUnit.Framework; +using Opc.Ua.Client; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Drives ยง5.6 history replay of against an + /// in-memory address space and a scripted HistoryRead service. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdConnectorHistoryTests + { + private FakeAddressSpace m_space = null!; + private Mock m_session = null!; + private MockUsdSink m_sink = null!; + private NodeId m_binding = NodeId.Null; + private NodeId m_signal = NodeId.Null; + private readonly Queue m_responses = new(); + private readonly List m_releaseFlags = []; + + [SetUp] + public void SetUp() + { + m_space = new FakeAddressSpace(); + m_session = FakeSession.Create(m_space); + m_sink = new MockUsdSink(); + m_responses.Clear(); + m_releaseFlags.Clear(); + + NodeId facility = m_space.AddObject(Opc.Ua.ObjectIds.Server, "OpenUSD", + browseNameNamespace: m_space.OpenUsdNamespaceIndex); + NodeId registry = m_space.AddObject(facility, "Representations"); + NodeId machine = m_space.AddObject(Opc.Ua.ObjectIds.Server, "Machine"); + m_signal = m_space.AddVariable(machine, "Speed", new Variant(0.0)); + NodeId rep = m_space.AddObject(registry, "Robot", + new NodeId(OpenUsdModel.RepresentationTypeId, m_space.OpenUsdNamespaceIndex)); + m_space.AddVariable(rep, "PrimPath", new Variant("/World/Robot")); + m_binding = m_space.AddObject(rep, "SpeedHistory", + new NodeId(OpenUsdModel.HistoryBindingTypeId, m_space.OpenUsdNamespaceIndex)); + m_space.AddVariable(m_binding, "SourceNodeId", new Variant(m_signal)); + m_space.AddVariable(m_binding, "TargetPropertyName", new Variant("speed")); + m_space.AddVariable(m_binding, "RenderTargetKind", + new Variant((int)OpenUsdRenderTargetKind.Custom)); + m_space.AddVariable(m_binding, "TimeSampled", new Variant(true)); + + m_session + .Setup(s => s.HistoryReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((RequestHeader _, ExtensionObject _, TimestampsToReturn _, + bool release, ArrayOf _, CancellationToken _) => + { + m_releaseFlags.Add(release); + return new ValueTask(m_responses.Count > 0 + ? m_responses.Dequeue() + : EmptyResponse()); + }); + } + + private OpenUsdConnector Connector() + { + return new OpenUsdConnector(m_session.Object, m_sink); + } + + private static HistoryReadResponse EmptyResponse() + { + return new HistoryReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = [], + DiagnosticInfos = [] + }; + } + + private static HistoryReadResponse Response( + StatusCode statusCode, ByteString continuationPoint, params DataValue[] values) + { + return new HistoryReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = + [ + new HistoryReadResult + { + StatusCode = statusCode, + ContinuationPoint = continuationPoint, + HistoryData = new ExtensionObject(new HistoryData + { + DataValues = values.ToArrayOf() + }) + } + ], + DiagnosticInfos = [] + }; + } + + private static DataValue Sample(double value, int secondsAgo) + { + return new DataValue( + new Variant(value), + StatusCodes.Good, + DateTimeUtc.From(DateTime.UtcNow.AddSeconds(-secondsAgo))); + } + + private Task ReplayAsync(OpenUsdConnector connector) + { + return connector.ReplayHistoryAsync( + DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, CancellationToken.None); + } + + [Test] + public async Task ReplayHistoryAuthorsOneTimeSamplePerGoodValueAsync() + { + m_responses.Enqueue(Response(StatusCodes.Good, default, + Sample(1.0, 3), Sample(2.0, 2), Sample(3.0, 1))); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.EqualTo(3)); + Assert.That(m_sink.TimeSampleWrites, Is.EqualTo(3)); + } + + [Test] + public async Task ReplayHistorySkipsValuesWithABadStatusCodeAsync() + { + var bad = new DataValue( + new Variant(9.0), StatusCodes.BadOutOfService, DateTimeUtc.Now); + m_responses.Enqueue(Response(StatusCodes.Good, default, Sample(1.0, 2), bad)); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.EqualTo(1)); + } + + [Test] + public async Task ReplayHistorySkipsValuesThatCannotBeConvertedAsync() + { + var unconvertible = new DataValue( + new Variant("fast"), StatusCodes.Good, DateTimeUtc.Now); + m_responses.Enqueue(Response(StatusCodes.Good, default, unconvertible)); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.Zero); + Assert.That(m_sink.TimeSampleWrites, Is.Zero); + } + + [Test] + public async Task ReplayHistoryFollowsContinuationPointsAsync() + { + m_responses.Enqueue(Response(StatusCodes.Good, + new ByteString(new byte[] { 7 }), Sample(1.0, 3))); + m_responses.Enqueue(Response(StatusCodes.Good, default, Sample(2.0, 2))); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.EqualTo(2)); + Assert.That(m_releaseFlags, Has.Count.EqualTo(2)); + Assert.That(m_releaseFlags, Has.None.True); + } + + [Test] + public async Task ReplayHistoryReleasesTheContinuationPointWhenTheServerFailsAsync() + { + m_responses.Enqueue(Response(StatusCodes.Good, + new ByteString(new byte[] { 7 }), Sample(1.0, 3))); + m_responses.Enqueue(Response(StatusCodes.BadTooManyOperations, + new ByteString(new byte[] { 7 }))); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.EqualTo(1)); + Assert.That(m_releaseFlags, Has.Some.True); + } + + [Test] + public async Task ReplayHistoryStopsWhenTheServerReturnsNoResultsAsync() + { + m_responses.Enqueue(EmptyResponse()); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.Zero); + } + + [Test] + public async Task ReplayHistoryDegradesWhenTheSourceDoesNotHistorizeAsync() + { + m_session + .Setup(s => s.HistoryReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Throws(new ServiceResultException(StatusCodes.BadHistoryOperationUnsupported)); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.Zero); + } + + [Test] + public async Task ReplayHistoryIgnoresAFailedContinuationReleaseAsync() + { + int calls = 0; + m_session + .Setup(s => s.HistoryReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((RequestHeader _, ExtensionObject _, TimestampsToReturn _, + bool release, ArrayOf _, CancellationToken _) => + { + calls++; + if (release) + { + throw new ServiceResultException(StatusCodes.BadContinuationPointInvalid); + } + return new ValueTask(calls == 1 + ? Response(StatusCodes.Good, new ByteString(new byte[] { 9 }), Sample(1.0, 1)) + : Response(StatusCodes.BadTooManyOperations, new ByteString(new byte[] { 9 }))); + }); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.EqualTo(1)); + Assert.That(calls, Is.EqualTo(3)); + } + + [Test] + public async Task ReplayHistorySkipsABindingThatIsNotTimeSampledAsync() + { + m_space.SetValue(ChildOfBinding("TimeSampled"), new DataValue(new Variant(false))); + m_responses.Enqueue(Response(StatusCodes.Good, default, Sample(1.0, 1))); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.Zero); + Assert.That(m_releaseFlags, Is.Empty); + } + + [Test] + public async Task ReplayHistorySkipsABindingSuppressedByItsEnabledTombstoneAsync() + { + m_space.AddVariable(m_binding, "Enabled", new Variant(false)); + m_responses.Enqueue(Response(StatusCodes.Good, default, Sample(1.0, 1))); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.Zero); + Assert.That(m_releaseFlags, Is.Empty); + } + + [Test] + public async Task ReplayHistorySkipsABindingWithAnUnresolvedSourceAsync() + { + m_space.SetValue(ChildOfBinding("SourceNodeId"), new DataValue(new Variant(NodeId.Null))); + m_responses.Enqueue(Response(StatusCodes.Good, default, Sample(1.0, 1))); + OpenUsdConnector connector = Connector(); + + int authored = await ReplayAsync(connector); + + Assert.That(authored, Is.Zero); + Assert.That(m_releaseFlags, Is.Empty); + } + + private NodeId ChildOfBinding(string browseName) + { + BrowseResponse response = m_space.Browse( + [ + new BrowseDescription + { + NodeId = m_binding, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true + } + ]); + ArrayOf refs = response.Results[0].References; + for (int i = 0; i < refs.Count; i++) + { + if (refs[i].BrowseName.Name == browseName) + { + return ExpandedNodeId.ToNodeId(refs[i].NodeId, m_space.NamespaceUris); + } + } + return NodeId.Null; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/OpenUsdRepresentationAuthoringTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdRepresentationAuthoringTests.cs new file mode 100644 index 0000000000..135d3ea7de --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OpenUsdRepresentationAuthoringTests.cs @@ -0,0 +1,255 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.OpenUsd; + +namespace Opc.Ua.OpenUsd.Server.Tests +{ + /// + /// Unit tests for : attaching an + /// OpenUsdRepresentation AddIn to any Object (spec ยง4.2), registering it in the + /// well-known discovery registry, and the fail-fast argument validation every authoring + /// helper performs before it touches the address space. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OpenUsdRepresentationAuthoringTests + { + [Test] + public void CreateRepresentationAttachesABrowsableAddInToTheOwner() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell"); + BaseObjectState owner = OpenUsdAuthoringHarness.NewOwner(context, ns, "Robot1"); + + OpenUsdRepresentationState rep = context.CreateRepresentation( + owner, stage.NodeId, "/Cell/Robots/R1", ns); + + Assert.That(rep.BrowseName.Name, Is.EqualTo("OpenUsdRepresentation")); + Assert.That(rep.BrowseName.NamespaceIndex, Is.EqualTo(ns)); + Assert.That(rep.ReferenceTypeId, Is.EqualTo(ReferenceTypeIds.HasComponent)); + Assert.That(rep.NodeId.IsNull, Is.False); + Assert.That(rep.Stage!.Value, Is.EqualTo(stage.NodeId)); + Assert.That(rep.PrimPath!.Value, Is.EqualTo("/Cell/Robots/R1")); + + var children = new List(); + owner.GetChildren(context, children); + Assert.That(children, Has.Member(rep)); + } + + [Test] + public void CreateRepresentationRejectsANullContext() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + BaseObjectState owner = OpenUsdAuthoringHarness.NewOwner(context, ns, "Robot1"); + + Assert.That( + () => ((ISystemContext)null!).CreateRepresentation( + owner, new NodeId(1u, ns), "/Cell", ns), + Throws.ArgumentNullException); + } + + [Test] + public void CreateRepresentationRejectsANullOwner() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + + Assert.That( + () => context.CreateRepresentation(null!, new NodeId(1u, ns), "/Cell", ns), + Throws.ArgumentNullException); + } + + [Test] + public void RegisterInDiscoveryAddsTheOrganizesReferencePair() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell"); + BaseObjectState owner = OpenUsdAuthoringHarness.NewOwner(context, ns, "Robot1"); + OpenUsdRepresentationState rep = context.CreateRepresentation( + owner, stage.NodeId, "/Cell/Robots/R1", ns); + FolderState registry = root.Representations!; + + rep.RegisterInDiscovery(registry); + + Assert.That( + registry.ReferenceExists(ReferenceTypeIds.Organizes, false, rep.NodeId), Is.True); + Assert.That( + rep.ReferenceExists(ReferenceTypeIds.Organizes, true, registry.NodeId), Is.True); + } + + [Test] + public void RegisterInDiscoveryIgnoresAMissingRegistry() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell"); + BaseObjectState owner = OpenUsdAuthoringHarness.NewOwner(context, ns, "Robot1"); + OpenUsdRepresentationState rep = context.CreateRepresentation( + owner, stage.NodeId, "/Cell/Robots/R1", ns); + + Assert.That(() => rep.RegisterInDiscovery(null!), Throws.Nothing); + + var references = new List(); + rep.GetReferences(context, references, ReferenceTypeIds.Organizes, false); + Assert.That(references, Is.Empty); + } + + [Test] + public void RegisterInDiscoveryRejectsANullRepresentation() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + + Assert.That( + () => ((OpenUsdRepresentationState)null!).RegisterInDiscovery(root.Representations!), + Throws.ArgumentNullException); + } + + [Test] + public void AddLiveBindingRejectsANullRepresentation() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + + Assert.That( + () => ((OpenUsdRepresentationState)null!).AddLiveBinding( + context, ns, new NodeId(1u, ns), "Pose", Guid.NewGuid(), new NodeId(2u, ns), + "/Cell/Robots/R1", "xformOp:rotateZ", "double", + OpenUsdRenderTargetKindEnum.Rotation, 1.0), + Throws.ArgumentNullException); + } + + [Test] + public void AddLiveBindingRejectsANullContext() + { + OpenUsdRepresentationState rep = NewRepresentation(out _, out ushort ns); + + Assert.That( + () => rep.AddLiveBinding( + null!, ns, new NodeId(1u, ns), "Pose", Guid.NewGuid(), new NodeId(2u, ns), + "/Cell/Robots/R1", "xformOp:rotateZ", "double", + OpenUsdRenderTargetKindEnum.Rotation, 1.0), + Throws.ArgumentNullException); + } + + [Test] + public void AddComponentBindingRejectsANullRepresentation() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + + Assert.That( + () => ((OpenUsdRepresentationState)null!).AddComponentBinding( + context, ns, "Tool", Guid.NewGuid(), OpenUsdCardinalityEnum.One, + OpenUsdCompositionArcEnum.Reference, "/Cell/Robots/R1/Tool"), + Throws.ArgumentNullException); + } + + [Test] + public void AddComponentBindingRejectsANullContext() + { + OpenUsdRepresentationState rep = NewRepresentation(out _, out ushort ns); + + Assert.That( + () => rep.AddComponentBinding( + null!, ns, "Tool", Guid.NewGuid(), OpenUsdCardinalityEnum.One, + OpenUsdCompositionArcEnum.Reference, "/Cell/Robots/R1/Tool"), + Throws.ArgumentNullException); + } + + [Test] + public void AddLiveBindingAuthorsTheMandatoryBaseMembers() + { + OpenUsdRepresentationState rep = NewRepresentation( + out SystemContext context, out ushort ns); + var stageId = new NodeId(4242u, ns); + var sourceId = new NodeId(77u, ns); + Guid definition = Guid.NewGuid(); + + OpenUsdLiveBindingState binding = rep.AddLiveBinding( + context, ns, stageId, "Pose", definition, sourceId, + "/Cell/Robots/R1", "xformOp:rotateZ", "double", + OpenUsdRenderTargetKindEnum.Rotation, 2.5); + + Assert.That(binding.BindingDefinitionId!.Value, Is.EqualTo(new Uuid(definition))); + Assert.That(binding.Enabled!.Value, Is.True); + Assert.That(binding.TargetStage!.Value, Is.EqualTo(stageId)); + Assert.That(binding.TargetPrimPath!.Value, Is.EqualTo("/Cell/Robots/R1")); + Assert.That(binding.TargetPropertyName!.Value, Is.EqualTo("xformOp:rotateZ")); + Assert.That(binding.TargetUsdTypeName!.Value, Is.EqualTo("double")); + Assert.That(binding.SourceNodeId!.Value, Is.EqualTo(sourceId)); + Assert.That(binding.Scale!.Value, Is.EqualTo(2.5).Within(1e-12)); + Assert.That( + binding.RenderTargetKind!.Value, Is.EqualTo(OpenUsdRenderTargetKindEnum.Rotation)); + Assert.That( + binding.BadQualityAction!.Value, Is.EqualTo(OpenUsdBadQualityActionEnum.Skip)); + } + + [Test] + public void AddComponentBindingAuthorsTheMandatoryBaseMembers() + { + OpenUsdRepresentationState rep = NewRepresentation( + out SystemContext context, out ushort ns); + Guid definition = Guid.NewGuid(); + + OpenUsdComponentBindingState binding = rep.AddComponentBinding( + context, ns, "Tool", definition, OpenUsdCardinalityEnum.Many, + OpenUsdCompositionArcEnum.Reference, "/Cell/Robots/R1/Tool", + assetReference: "@tool.usda@", dynamic: true); + + Assert.That(binding.BindingDefinitionId!.Value, Is.EqualTo(new Uuid(definition))); + Assert.That(binding.Enabled!.Value, Is.True); + Assert.That(binding.Cardinality!.Value, Is.EqualTo(OpenUsdCardinalityEnum.Many)); + Assert.That( + binding.CompositionArc!.Value, Is.EqualTo(OpenUsdCompositionArcEnum.Reference)); + Assert.That(binding.TargetPrimPath!.Value, Is.EqualTo("/Cell/Robots/R1/Tool")); + Assert.That( + binding.ComponentAssetReference!.Value, Is.EqualTo("@tool.usda@")); + Assert.That(binding.Dynamic!.Value, Is.True); + } + + private static OpenUsdRepresentationState NewRepresentation( + out SystemContext context, out ushort ns) + { + (SystemContext created, ushort companionNs) = OpenUsdAuthoringHarness.NewContext(); + context = created; + ns = companionNs; + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(created, companionNs); + OpenUsdStageState stage = + OpenUsdAuthoringHarness.NewStage(created, root, companionNs, "Cell"); + BaseObjectState owner = + OpenUsdAuthoringHarness.NewOwner(created, companionNs, "Robot1"); + return created.CreateRepresentation( + owner, stage.NodeId, "/Cell/Robots/R1", companionNs); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/OptionalMemberMaterializationTests.cs b/tests/Opc.Ua.OpenUsd.Tests/OptionalMemberMaterializationTests.cs new file mode 100644 index 0000000000..bf65f476f0 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/OptionalMemberMaterializationTests.cs @@ -0,0 +1,263 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Unit tests for the optional members the materializer authors only when the source scene + /// actually carries them โ€” stage time-code and mass metadata, per-prim instanceability and + /// documentation, attribute interpolation, custom relationships, and variant-selecting + /// composition arcs โ€” plus the fail-fast argument validation of the entry point. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class OptionalMemberMaterializationTests + { + [Test] + public void MaterializeUsdStageRejectsANullContext() + { + (SystemContext _, ushort ns, BaseObjectState root) = MaterializationHarness.NewContext(); + + Assert.That( + () => ((ISystemContext)null!).MaterializeUsdStage(root, new UsdStage("Test"), ns), + Throws.ArgumentNullException); + } + + [Test] + public void MaterializeUsdStageRejectsANullParent() + { + (SystemContext context, ushort ns, BaseObjectState _) = + MaterializationHarness.NewContext(); + + Assert.That( + () => context.MaterializeUsdStage(null!, new UsdStage("Test"), ns), + Throws.ArgumentNullException); + } + + [Test] + public void MaterializeUsdStageRejectsANullStage() + { + (SystemContext context, ushort ns, BaseObjectState root) = + MaterializationHarness.NewContext(); + + Assert.That( + () => context.MaterializeUsdStage(root, null!, ns), Throws.ArgumentNullException); + } + + [Test] + public void AStageAuthorsItsOptionalTimeCodeAndMassMetadata() + { + var stage = new UsdStage("Test") + { + KilogramsPerUnit = 0.001, + TimeCodesPerSecond = 48.0, + StartTimeCode = -12.0, + EndTimeCode = 96.0 + }; + + MaterializedScene scene = MaterializationHarness.Materialize(stage); + + Assert.That(scene.Stage.KilogramsPerUnit!.Value, Is.EqualTo(0.001).Within(1e-12)); + Assert.That(scene.Stage.TimeCodesPerSecond!.Value, Is.EqualTo(48.0).Within(1e-12)); + Assert.That(scene.Stage.StartTimeCode!.Value, Is.EqualTo(-12.0).Within(1e-12)); + Assert.That(scene.Stage.EndTimeCode!.Value, Is.EqualTo(96.0).Within(1e-12)); + } + + [Test] + public void AStageWithoutOptionalTimeCodeMetadataAuthorsNoSuchMembers() + { + MaterializedScene scene = MaterializationHarness.Materialize(new UsdStage("Test")); + + Assert.That(scene.Stage.KilogramsPerUnit, Is.Null); + Assert.That(scene.Stage.TimeCodesPerSecond, Is.Null); + Assert.That(scene.Stage.StartTimeCode, Is.Null); + Assert.That(scene.Stage.EndTimeCode, Is.Null); + } + + [Test] + public void APrimAuthorsInstanceableAndDocumentationWhenAuthored() + { + var prim = new UsdPrim("Cube", "Cube") + { + Instanceable = true, + Documentation = "A reusable cube." + }; + + UsdPrimState node = MaterializeSinglePrim(prim, out _); + + Assert.That(node.Instanceable!.Value, Is.True); + Assert.That(node.Documentation!.Value, Is.EqualTo("A reusable cube.")); + } + + [Test] + public void APrimWithoutInstanceableOrDocumentationAuthorsNoSuchMembers() + { + UsdPrimState node = MaterializeSinglePrim(new UsdPrim("Cube", "Cube"), out _); + + Assert.That(node.Instanceable, Is.Null); + Assert.That(node.Documentation, Is.Null); + } + + [Test] + public void AnAttributeAuthorsItsInterpolationWhenAuthored() + { + var attribute = new UsdAttribute("points", "point3f[]") + { + Interpolation = "vertex" + }; + var prim = new UsdPrim("Mesh", "Mesh"); + prim.Attributes.Add(attribute); + + UsdPrimState node = MaterializeSinglePrim(prim, out MaterializedScene scene); + List attributes = + MaterializationHarness.ChildrenOfType(scene.Context, node); + + Assert.That(attributes, Has.Count.EqualTo(1)); + Assert.That(attributes[0].Interpolation!.Value, Is.EqualTo("vertex")); + } + + [Test] + public void ACustomRelationshipAuthorsTheCustomFlag() + { + var relationship = new UsdRelationship("material:binding") { Custom = true }; + relationship.Targets.Add("/Materials/Steel"); + var prim = new UsdPrim("Mesh", "Mesh"); + prim.Relationships.Add(relationship); + + UsdPrimState node = MaterializeSinglePrim(prim, out MaterializedScene scene); + List relationships = + MaterializationHarness.ChildrenOfType(scene.Context, node); + + Assert.That(relationships, Has.Count.EqualTo(1)); + Assert.That(relationships[0].Custom!.Value, Is.True); + } + + [Test] + public void ARelationshipTargetingAnAttributeResolvesToThatAttributeNode() + { + var target = new UsdPrim("Mesh", "Mesh"); + target.Attributes.Add(new UsdAttribute("size", "double") { Value = 2.0 }); + var source = new UsdPrim("Binding", "Scope"); + var relationship = new UsdRelationship("drivenBy"); + relationship.Targets.Add("/Mesh.size"); + source.Relationships.Add(relationship); + + var stage = new UsdStage("Test"); + stage.AddRootPrim(target); + stage.AddRootPrim(source); + MaterializedScene scene = MaterializationHarness.Materialize(stage); + + List prims = + MaterializationHarness.ChildrenOfType(scene.Context, scene.Stage); + UsdPrimState bindingNode = prims[1]; + List relationships = + MaterializationHarness.ChildrenOfType( + scene.Context, bindingNode); + + Assert.That(relationships, Has.Count.EqualTo(1)); + ArrayOf targets = relationships[0].Targets!.Value; + Assert.That(targets.Count, Is.EqualTo(1)); + Assert.That(targets[0].IsNull, Is.False, "An in-subtree attribute target must resolve."); + } + + [Test] + public void ARelationshipTargetingAnUnknownPathKeepsAPlaceholder() + { + var source = new UsdPrim("Binding", "Scope"); + var relationship = new UsdRelationship("drivenBy"); + relationship.Targets.Add("/Nowhere.size"); + source.Relationships.Add(relationship); + + UsdPrimState node = MaterializeSinglePrim(source, out MaterializedScene scene); + List relationships = + MaterializationHarness.ChildrenOfType(scene.Context, node); + + ArrayOf targets = relationships[0].Targets!.Value; + Assert.That(targets.Count, Is.EqualTo(1)); + Assert.That(targets[0].IsNull, Is.True, "An out-of-subtree target keeps a placeholder."); + } + + [Test] + public void AVariantSelectingCompositionArcAuthorsItsSetAndSelection() + { + var arc = new UsdCompositionArc(UsdArcKindEnum.VariantSet) + { + VariantSet = "modelingVariant", + VariantSelection = "highRes" + }; + var prim = new UsdPrim("Cube", "Cube"); + prim.Composition.Add(arc); + + UsdPrimState node = MaterializeSinglePrim(prim, out MaterializedScene scene); + List arcs = + MaterializationHarness.ChildrenOfType( + scene.Context, node.Composition!); + + Assert.That(arcs, Has.Count.EqualTo(1)); + Assert.That(arcs[0].VariantSet!.Value, Is.EqualTo("modelingVariant")); + Assert.That(arcs[0].VariantSelection!.Value, Is.EqualTo("highRes")); + } + + [Test] + public void ACompositionArcWithoutAVariantSelectionAuthorsNoSuchMembers() + { + var arc = new UsdCompositionArc(UsdArcKindEnum.Reference) + { + AssetPath = "@cube.usda@", + PrimPath = "/Cube" + }; + var prim = new UsdPrim("Cube", "Cube"); + prim.Composition.Add(arc); + + UsdPrimState node = MaterializeSinglePrim(prim, out MaterializedScene scene); + List arcs = + MaterializationHarness.ChildrenOfType( + scene.Context, node.Composition!); + + Assert.That(arcs[0].VariantSet, Is.Null); + Assert.That(arcs[0].VariantSelection, Is.Null); + } + + private static UsdPrimState MaterializeSinglePrim(UsdPrim prim, out MaterializedScene scene) + { + var stage = new UsdStage("Test"); + stage.AddRootPrim(prim); + scene = MaterializationHarness.Materialize(stage); + List prims = + MaterializationHarness.ChildrenOfType(scene.Context, scene.Stage); + Assert.That(prims, Has.Count.EqualTo(1)); + return prims[0]; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/PrimMetadataMaterializationTests.cs b/tests/Opc.Ua.OpenUsd.Tests/PrimMetadataMaterializationTests.cs new file mode 100644 index 0000000000..6afd18753a --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/PrimMetadataMaterializationTests.cs @@ -0,0 +1,207 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Custom prim metadata materialization and export (ยง6.3). Each authored metadata entry is + /// materialized as a Property carrying its value in its own DataType โ€” not stringified โ€” so it + /// round-trips through the address space with its type intact, and a nested dictionary is + /// materialized as a nested Metadata/ folder that recovers as a nested dictionary. + /// + [TestFixture] + public class PrimMetadataMaterializationTests + { + // ---- Typed scalar metadata round-trips (ยง6.3) ---------------------------------- + + [Test] + public void Metadata_ScalarTypes_RoundTrip() + { + var stage = new UsdStage("S") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Metadata["author"] = "Ada"; + prim.Metadata["visible"] = true; + prim.Metadata["count"] = 7; + prim.Metadata["huge"] = 9_000_000_000L; + prim.Metadata["scale"] = 2.5; + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + + IDictionary md = PrimOf(exported, "P").Metadata; + Assert.Multiple(() => + { + Assert.That(md["author"], Is.EqualTo("Ada")); + Assert.That(md["visible"], Is.True); + Assert.That(md["count"], Is.EqualTo(7)); + Assert.That(md["huge"], Is.EqualTo(9_000_000_000L)); + Assert.That(md["scale"], Is.EqualTo(2.5)); + }); + } + + [Test] + public void Metadata_IsMaterializedTyped_NotStringified() + { + // The defect this pins: metadata used to be stringified, losing its type. Each leaf + // must now carry the exact DataType and a value in that type, so a client reads the + // authored kind rather than an opaque string. + var stage = new UsdStage("S") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Metadata["count"] = 7; + prim.Metadata["scale"] = 2.5; + prim.Metadata["visible"] = true; + prim.Metadata["author"] = "Ada"; + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + Assert.Multiple(() => + { + PropertyState count = MetaProperty(ms, "/P", "count"); + Assert.That(count.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int32)); + Assert.That(count.Value.AsBoxedObject(), Is.EqualTo(7)); + + PropertyState scale = MetaProperty(ms, "/P", "scale"); + Assert.That(scale.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Double)); + Assert.That(scale.Value.AsBoxedObject(), Is.EqualTo(2.5)); + + PropertyState visible = MetaProperty(ms, "/P", "visible"); + Assert.That(visible.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Boolean)); + Assert.That(visible.Value.AsBoxedObject(), Is.True); + + PropertyState author = MetaProperty(ms, "/P", "author"); + Assert.That(author.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.String)); + Assert.That(author.Value.AsBoxedObject(), Is.EqualTo("Ada")); + }); + } + + [Test] + public void Metadata_IntArray_RoundTrips_AsTypedSequence() + { + var stage = new UsdStage("S") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.Metadata["order"] = new int[] { 3, 1, 2 }; + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + // The materialized value is a typed one-dimensional Int32 array, not text. + PropertyState order = MetaProperty(ms, "/P", "order"); + Assert.That(order.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.Int32)); + Assert.That(order.ValueRank, Is.EqualTo(ValueRanks.OneDimension)); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + IDictionary md = PrimOf(exported, "P").Metadata; + Assert.That(md["order"], Is.EqualTo(new[] { 3, 1, 2 }), + "The array must round-trip element-wise, preserving order."); + } + + // ---- Nested metadata maps to nested folders (ยง6.3) ----------------------------- + + [Test] + public void Metadata_NestedDictionary_RoundTrips() + { + var stage = new UsdStage("S") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + var customData = new Dictionary(StringComparer.Ordinal) + { + ["author"] = "Ada", + ["revision"] = new Dictionary(StringComparer.Ordinal) + { + ["major"] = 1, + ["minor"] = 2 + } + }; + prim.Metadata["customData"] = customData; + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + // A nested dictionary is materialized as a nested Metadata folder (a FolderState child), + // so the structure is browsable as authored. + NodeState? metadataFolder = ms.Prim("/P").Metadata; + Assert.That(metadataFolder, Is.Not.Null); + List subFolders = + MaterializationHarness.ChildrenOfType(ms.Context, metadataFolder!); + Assert.That(subFolders, Has.Count.EqualTo(1), + "The nested dictionary must materialize as one nested Metadata sub-folder."); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + IDictionary md = PrimOf(exported, "P").Metadata; + + Assert.That(md["customData"], Is.InstanceOf>()); + var exportedCustom = (IDictionary)md["customData"]!; + Assert.That(exportedCustom["author"], Is.EqualTo("Ada")); + + Assert.That(exportedCustom["revision"], Is.InstanceOf>()); + var revision = (IDictionary)exportedCustom["revision"]!; + Assert.Multiple(() => + { + Assert.That(revision["major"], Is.EqualTo(1)); + Assert.That(revision["minor"], Is.EqualTo(2)); + }); + } + + private static UsdPrim PrimOf(UsdStage stage, string name) + { + foreach (UsdPrim prim in stage.RootPrims) + { + if (string.Equals(prim.Name, name, StringComparison.Ordinal)) + { + return prim; + } + } + Assert.Fail($"Prim {name} was not found in the exported stage."); + return new UsdPrim(name); + } + + private static PropertyState MetaProperty(MaterializedScene ms, string primPath, string key) + { + NodeState? folder = ms.Prim(primPath).Metadata; + Assert.That(folder, Is.Not.Null, $"Prim {primPath} has no Metadata folder."); + foreach (PropertyState property in + MaterializationHarness.ChildrenOfType(ms.Context, folder!)) + { + if (string.Equals(property.BrowseName.Name, key, StringComparison.Ordinal)) + { + return property; + } + } + Assert.Fail($"Metadata property {key} not found on {primPath}."); + return null!; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/RobotAssetContractTests.cs b/tests/Opc.Ua.OpenUsd.Tests/RobotAssetContractTests.cs new file mode 100644 index 0000000000..a6a6fa4ea0 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/RobotAssetContractTests.cs @@ -0,0 +1,263 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Guards the USD assets that MinimalRobotServer ships and serves. The server's + /// OpenUSD bindings address these prims and properties by name, so an asset edit that + /// renames or drops one silently stops the twin from articulating. Parsing the shipped + /// files here turns that into a build failure. + /// + /// + /// These are the files the server embeds, linked in from + /// samples/MinimalRobotServer/Assets. They are deliberately separate from the + /// frozen reader/materializer fixtures in Assets. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class RobotAssetContractTests + { + /// + /// The six axis link prims and the rotate op each one's ActualPosition drives, as + /// declared by RobotCell.s_axisTemplate. + /// + private static readonly (string PrimPath, string RotateOp)[] s_axisContract = + [ + ("/Robot/Base/J1", "xformOp:rotateZ"), + ("/Robot/Base/J1/J2", "xformOp:rotateY"), + ("/Robot/Base/J1/J2/J3", "xformOp:rotateY"), + ("/Robot/Base/J1/J2/J3/J4", "xformOp:rotateX"), + ("/Robot/Base/J1/J2/J3/J4/J5", "xformOp:rotateY"), + ("/Robot/Base/J1/J2/J3/J4/J5/J6", "xformOp:rotateX") + ]; + + private static string SamplePath(string name) => + Path.Combine(AppContext.BaseDirectory, "Assets", "Sample", name); + + private static UsdStage LoadSample(string name) => UsdaReader.ParseFile(SamplePath(name)); + + /// + /// Renders a parsed attribute value as text regardless of how the reader models + /// arrays, so an assertion does not depend on that representation. + /// + private static string Flatten(object? value) => value switch + { + null => string.Empty, + string text => text, + System.Collections.IEnumerable items => + string.Join(",", items.Cast().Select(Flatten)), + _ => value.ToString() ?? string.Empty + }; + + [Test] + [TestCase("robot.usda", "Robot")] + [TestCase("tool.usda", "Gripper")] + [TestCase("Cell.usda", "Cell")] + public void ShippedAssetParsesWithTheExpectedStageMetadata(string asset, string defaultPrim) + { + UsdStage stage = LoadSample(asset); + + Assert.That(stage.DefaultPrim, Is.EqualTo(defaultPrim)); + Assert.That(stage.UpAxis, Is.EqualTo("Z")); + Assert.That(stage.MetersPerUnit, Is.EqualTo(1.0)); + } + + [Test] + public void RobotExposesEveryAxisLinkAndItsRotateOp() + { + UsdStage stage = LoadSample("robot.usda"); + + foreach ((string primPath, string rotateOp) in s_axisContract) + { + UsdPrim? link = stage.Find(primPath); + Assert.That(link, Is.Not.Null, $"Axis link prim {primPath} is missing."); + + UsdAttribute? rotate = link!.Attributes.FirstOrDefault(a => a.Name == rotateOp); + Assert.That(rotate, Is.Not.Null, + $"Axis link prim {primPath} does not carry {rotateOp}."); + Assert.That(rotate!.TypeName, Is.EqualTo("double"), + $"{primPath}.{rotateOp} must stay a scalar double; the connector writes it as one."); + + UsdAttribute? order = link.Attributes.FirstOrDefault(a => a.Name == "xformOpOrder"); + Assert.That(order, Is.Not.Null, $"{primPath} has no xformOpOrder."); + Assert.That( + Flatten(order!.Value), + Does.Contain(rotateOp), + $"{primPath} does not list {rotateOp} in its xformOpOrder."); + } + } + + [Test] + public void RobotExposesTheToolFlangeMountPoint() + { + UsdStage stage = LoadSample("robot.usda"); + + // RobotCell.ToolSuffix composes the gripper at this path plus "/Tool". + Assert.That(stage.Find("/Robot/Base/J1/J2/J3/J4/J5/J6/Flange"), Is.Not.Null, + "The tool flange mount point is missing."); + } + + [Test] + public void RobotExposesTheEmergencyStopWarningVisibility() + { + UsdStage stage = LoadSample("robot.usda"); + + UsdPrim? warning = stage.Find("/Robot/Warning"); + Assert.That(warning, Is.Not.Null, "The emergency-stop warning prim is missing."); + + UsdAttribute? visibility = + warning!.Attributes.FirstOrDefault(a => a.Name == "visibility"); + Assert.That(visibility, Is.Not.Null, "/Robot/Warning has no visibility attribute."); + Assert.That(visibility!.TypeName, Is.EqualTo("token")); + Assert.That(visibility.Value, Is.EqualTo("invisible"), + "The warning halo must start hidden."); + } + + [Test] + public void GripperExposesTheReferencedRootPrim() + { + UsdStage stage = LoadSample("tool.usda"); + + // The connector references @tool.usda@ onto the flange. + Assert.That(stage.Find("/Gripper"), Is.Not.Null, "The gripper root prim is missing."); + } + + [Test] + public void CellExposesTheSpeedOverrideCommandTarget() + { + UsdStage stage = LoadSample("Cell.usda"); + + UsdPrim? cell = stage.Find("/Cell"); + Assert.That(cell, Is.Not.Null); + + UsdAttribute? speed = + cell!.Attributes.FirstOrDefault(a => a.Name == "inputs:speedOverride"); + Assert.That(speed, Is.Not.Null, "/Cell has no inputs:speedOverride command target."); + Assert.That(speed!.Custom, Is.True); + Assert.That(speed.TypeName, Is.EqualTo("double")); + } + + [Test] + public void CellExposesTheSafetyBeaconVisibility() + { + UsdStage stage = LoadSample("Cell.usda"); + + UsdPrim? beacon = stage.Find("/Cell/SafetyBeacon"); + Assert.That(beacon, Is.Not.Null, "The safety beacon prim is missing."); + + UsdAttribute? visibility = + beacon!.Attributes.FirstOrDefault(a => a.Name == "visibility"); + Assert.That(visibility, Is.Not.Null, "/Cell/SafetyBeacon has no visibility attribute."); + Assert.That(visibility!.Value, Is.EqualTo("invisible"), + "The beacon must start hidden."); + } + + [Test] + [TestCase("/Cell/Robots/R1")] + [TestCase("/Cell/Robots/R2")] + public void CellExposesTheRobotMountPointWithItsPositioningAttributes(string primPath) + { + UsdStage stage = LoadSample("Cell.usda"); + + UsdPrim? mount = stage.Find(primPath); + Assert.That(mount, Is.Not.Null, $"Robot mount point {primPath} is missing."); + + foreach (string expected in new[] + { + "inputs:longitude", + "inputs:latitude", + "inputs:elevation" + }) + { + Assert.That( + mount!.Attributes.Any(a => a.Name == expected), Is.True, + $"{primPath} does not carry {expected}."); + } + } + + [Test] + [TestCase("/Cell/Robots/R1")] + [TestCase("/Cell/Robots/R2")] + public void RobotMountPointDeclaresASingleMatrixTransformOp(string primPath) + { + UsdStage stage = LoadSample("Cell.usda"); + + UsdPrim? mount = stage.Find(primPath); + Assert.That(mount, Is.Not.Null); + + // The connector authors this prim's pose into the root layer. A translate or + // rotate op order declared here, in a weaker sublayer, could not be cleared + // from there, so the mount point must expose exactly one matrix op to set. + Assert.That( + mount!.Attributes.Any(a => a.Name == "xformOp:transform"), Is.True, + $"{primPath} must declare xformOp:transform for the connector to set."); + + UsdAttribute? order = + mount.Attributes.FirstOrDefault(a => a.Name == "xformOpOrder"); + Assert.That(order, Is.Not.Null, $"{primPath} has no xformOpOrder."); + Assert.That( + Flatten(order!.Value), Is.EqualTo("xformOp:transform"), + $"{primPath} must order exactly the matrix op."); + } + + [Test] + public void RobotKeepsTheKukaKr16ReachSoTheCellStaysCorrectlyScaled() + { + UsdStage stage = LoadSample("robot.usda"); + + // 260 + 680 + 670 mm between the A1, A2, A3 and A4 axes is the published + // 1611 mm reach of the reference robot. + Assert.That(TranslateX(stage, "/Robot/Base/J1/J2"), Is.EqualTo(0.26).Within(1e-6)); + Assert.That(TranslateX(stage, "/Robot/Base/J1/J2/J3"), Is.EqualTo(0.68).Within(1e-6)); + Assert.That(TranslateX(stage, "/Robot/Base/J1/J2/J3/J4"), Is.EqualTo(0.67).Within(1e-6)); + } + + private static double TranslateX(UsdStage stage, string primPath) + { + UsdPrim? prim = stage.Find(primPath); + Assert.That(prim, Is.Not.Null, $"{primPath} is missing."); + + UsdAttribute? translate = + prim!.Attributes.FirstOrDefault(a => a.Name == "xformOp:translate"); + Assert.That(translate, Is.Not.Null, $"{primPath} has no xformOp:translate."); + + object?[]? components = translate!.Value as object?[]; + Assert.That(components, Is.Not.Null.And.Length.EqualTo(3)); + return Convert.ToDouble(components![0], System.Globalization.CultureInfo.InvariantCulture); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/SceneFidelityRoundTripTests.cs b/tests/Opc.Ua.OpenUsd.Tests/SceneFidelityRoundTripTests.cs new file mode 100644 index 0000000000..3efa5f47ce --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/SceneFidelityRoundTripTests.cs @@ -0,0 +1,160 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// End-to-end fidelity across the whole pipeline, exercising both closed gaps at once โ€” + /// variant branches (ยง5.6) and time samples (ยง7.1 step 3, ยง7.2, ยง9). A hand-authored + /// .usda is parsed, materialized into an address space, exported back, and the composed + /// scene is asserted variant-selection-equivalent and sample-preserving through the + /// ยง7.4 signature, while the un-composed branch structure is confirmed to survive structurally. + /// + [TestFixture] + public class SceneFidelityRoundTripTests + { + private const string Source = + "#usda 1.0\n" + + "(\n" + + " defaultPrim = \"Turntable\"\n" + + " metersPerUnit = 1.0\n" + + " upAxis = \"Z\"\n" + + " timeCodesPerSecond = 24\n" + + ")\n" + + "\n" + + "def Xform \"Turntable\" (\n" + + " variants = {\n" + + " string lod = \"high\"\n" + + " }\n" + + ")\n" + + "{\n" + + " double spin = 5.0\n" + + " double spin.timeSamples = {\n" + + " 0: 0.0,\n" + + " 24: 90.0,\n" + + " 48: 180.0,\n" + + " }\n" + + " variantSet \"lod\" = {\n" + + " \"high\" {\n" + + " int resolution = 1024\n" + + " }\n" + + " \"low\" {\n" + + " int resolution = 256\n" + + " }\n" + + " }\n" + + "}\n"; + + [Test] + public void ParseMaterializeExport_IsSignatureEquivalent() + { + UsdStage parsed = UsdaReader.Parse(Source, "Turntable"); + + MaterializedScene ms = MaterializationHarness.Materialize(parsed); + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + + // ยง7.4: the composed scene must be variant-selection-equivalent and preserve its + // sampled data across materialize -> export. + string? difference = UsdSceneSignature.FirstDifference(parsed, exported); + Assert.That( + UsdSceneSignature.Compute(exported), + Is.EqualTo(UsdSceneSignature.Compute(parsed)), + difference ?? "signatures are unexpectedly equal"); + } + + [Test] + public void ParseMaterializeExportWrite_ReparsesToTheSameSignature() + { + UsdStage parsed = UsdaReader.Parse(Source, "Turntable"); + + MaterializedScene ms = MaterializationHarness.Materialize(parsed); + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + + // Close the loop through the writer: the exported scene must re-serialize and re-parse + // to an identical signature, so the full .usda -> materialize -> export -> .usda cycle + // is lossless for the composed scene. + string written = UsdaWriter.Write(exported); + UsdStage reparsed = UsdaReader.Parse(written, "Turntable"); + + Assert.That( + UsdSceneSignature.Compute(reparsed), + Is.EqualTo(UsdSceneSignature.Compute(parsed)), + UsdSceneSignature.FirstDifference(parsed, reparsed) ?? "signatures equal"); + } + + [Test] + public void ExportedScene_RecoversSamples_AndBranches_Structurally() + { + UsdStage parsed = UsdaReader.Parse(Source, "Turntable"); + MaterializedScene ms = MaterializationHarness.Materialize(parsed); + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + + UsdPrim prim = exported.Find("/Turntable")!; + + // Time samples: the default and every sample survive the address-space round trip. + UsdAttribute spin = prim.Attributes.Single(a => a.Name == "spin"); + Assert.That(spin.Value, Is.EqualTo(5.0)); + Assert.That(spin.TimeSamples.Keys, Is.EqualTo(new[] { 0.0, 24.0, 48.0 })); + Assert.That(spin.TimeSamples[48.0], Is.EqualTo(180.0)); + + // Variant branches: the selection and every authored branch (with body) survive. + UsdVariantSet set = prim.VariantSets.Single(); + Assert.That(set.SetName, Is.EqualTo("lod")); + Assert.That(set.Selection, Is.EqualTo("high")); + Assert.That(set.Variants.Select(v => v.Name), Is.EqualTo(new[] { "high", "low" })); + Assert.That( + set.Variants[0].Attributes.Single(a => a.Name == "resolution").Value, + Is.EqualTo(1024L)); + Assert.That( + set.Variants[1].Attributes.Single(a => a.Name == "resolution").Value, + Is.EqualTo(256L)); + } + + [Test] + public void MaterializedScene_ExposesHistoricalAccess_ForTheSampledAttribute() + { + UsdStage parsed = UsdaReader.Parse(Source, "Turntable"); + MaterializedScene ms = MaterializationHarness.Materialize(parsed); + + // The composed-scene HistoricalAccess surface carries exactly the one sampled + // attribute โ€” the variant-branch attribute ("resolution") is authoring provenance and + // is deliberately excluded (ยง7.4). + Assert.That( + ms.Result.HistoricalAccessByPath.Keys, Is.EqualTo(new[] { "/Turntable.spin" })); + UsdHistoricalAccess ha = ms.Result.HistoricalAccessByPath["/Turntable.spin"]; + Assert.That(ha.Samples.Select(s => s.TimeCode), Is.EqualTo(new[] { 0.0, 24.0, 48.0 })); + Assert.That(ms.Attr("/Turntable.spin").Historizing, Is.True); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/SceneQuery.cs b/tests/Opc.Ua.OpenUsd.Tests/SceneQuery.cs new file mode 100644 index 0000000000..c779dc876b --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/SceneQuery.cs @@ -0,0 +1,154 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using Opc.Ua.OpenUsdScene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Small query helpers over a , so the assertions in the + /// server test fixtures read close to the address-space concepts they check. + /// + internal static class SceneQuery + { + /// + /// The materialized prim at an absolute SdfPath (for example /Plant). + /// + public static UsdPrimState Prim(this MaterializedScene ms, string path) + { + return ms.Result.PrimsByPath[path]; + } + + /// + /// The materialized attribute keyed <primPath>.<name>. + /// + public static UsdAttributeState Attr(this MaterializedScene ms, string key) + { + return ms.Result.AttributesByPath[key]; + } + + /// + /// The attribute's ArrayDimensions as a plain array, or null for a scalar โ€” the + /// ArrayOf<uint> a materialized Variable carries unwraps to a + /// uint[]? that NUnit compares element-wise. + /// + public static uint[]? Dims(this UsdAttributeState attribute) + { + return attribute.ArrayDimensions.ToArray(); + } + + /// + /// The boxed value of a materialized attribute, or null when unset. + /// + public static object? BoxedValue(this UsdAttributeState attribute) + { + return attribute.Value.AsBoxedObject(); + } + + /// + /// The materialized AddIns of type under a prim's + /// AppliedSchemas folder, in address-space order. + /// + public static List AppliedSchemas(this MaterializedScene ms, string primPath) + where T : NodeState + { + FolderState? folder = ms.Prim(primPath).AppliedSchemas; + if (folder == null) + { + return new List(); + } + return MaterializationHarness.ChildrenOfType(ms.Context, folder); + } + + /// + /// The materialized composition arcs under a prim's Composition folder, in + /// address-space order. + /// + public static List CompositionArcs( + this MaterializedScene ms, string primPath) + { + FolderState? folder = ms.Prim(primPath).Composition; + if (folder == null) + { + return new List(); + } + return MaterializationHarness.ChildrenOfType(ms.Context, folder); + } + + /// + /// The absolute SdfPath of a parsed prim, walked from its parent chain. + /// + public static string PathOf(this Scene.UsdPrim prim) + { + var parts = new List(); + Scene.UsdPrim? current = prim; + while (current != null) + { + parts.Insert(0, current.Name); + current = current.Parent; + } + return "/" + string.Join("/", parts); + } + + /// + /// The total number of authored attribute connections across a whole scene. + /// + public static int TotalConnections(this Scene.UsdStage stage) + { + int total = 0; + foreach (Scene.UsdPrim prim in stage.AllPrims()) + { + foreach (Scene.UsdAttribute attribute in prim.Attributes) + { + total += attribute.Connections.Count; + } + } + return total; + } + + /// + /// Nulls every attribute value and clears every attribute connection across a scene, so + /// two scenes can be compared on structure and provenance alone. This is the workaround + /// for the two exporter round-trip defects the fixtures pin separately. + /// + public static Scene.UsdStage NormalizeValuesAndConnections(this Scene.UsdStage stage) + { + foreach (Scene.UsdPrim prim in stage.AllPrims()) + { + foreach (Scene.UsdAttribute attribute in prim.Attributes) + { + attribute.Value = null; + attribute.Connections.Clear(); + } + } + return stage; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/TargetNodeIdAuthoringTests.cs b/tests/Opc.Ua.OpenUsd.Tests/TargetNodeIdAuthoringTests.cs new file mode 100644 index 0000000000..7591fb286b --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/TargetNodeIdAuthoringTests.cs @@ -0,0 +1,267 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Part 1 interoperability (ยง10): a Server hosting several materialized stages disambiguates + /// a binding target by its TargetStage, and can author Part 1's optional + /// TargetNodeId alongside the mandatory path form so NodeId-resolving and + /// path-resolving connectors agree. The TargetNodeId is authored generically (by + /// BrowseName), so Part 2 has no compile-time dependency on the Part 1 model (ยง4.2). + /// + [TestFixture] + public class TargetNodeIdAuthoringTests + { + // ---- Stage-aware resolution (ยง10) ---------------------------------------------- + + [Test] + public void StageAware_Resolves_OnlyForMatchingStage() + { + (_, _, UsdMaterializationResult a, UsdMaterializationResult b) = TwoStages(); + + // Result A answers for its own stage. + Assert.That( + a.TryResolveBindingTarget(a.Stage.NodeId, "/Shared", "value", out UsdAttributeState? fromA), + Is.True); + Assert.That(ReferenceEquals(fromA, a.AttributesByPath["/Shared.value"]), Is.True); + + // Result A must NOT answer for a different stage even though the SdfPath exists in it. + Assert.That( + a.TryResolveBindingTarget(b.Stage.NodeId, "/Shared", "value", out UsdAttributeState? none), + Is.False); + Assert.That(none, Is.Null); + } + + [Test] + public void StageAware_NullStage_FailsClosed() + { + (_, _, UsdMaterializationResult a, _) = TwoStages(); + + // The stage-aware form requires a stage; a null NodeId never silently matches. + Assert.That(a.TryResolveBindingTarget(NodeId.Null, "/Shared", "value", out _), Is.False); + Assert.That( + new[] { a }.ResolveBindingTargetNodeId(NodeId.Null, "/Shared", "value").IsNull, + Is.True); + } + + [Test] + public void MultiStage_DisambiguatesByStage_ReturningTheCorrectStagesNode() + { + (_, _, UsdMaterializationResult a, UsdMaterializationResult b) = TwoStages(); + var all = new[] { a, b }; + + Assert.That( + all.TryResolveBindingTarget(a.Stage.NodeId, "/Shared", "value", out UsdAttributeState? rA), + Is.True); + Assert.That( + all.TryResolveBindingTarget(b.Stage.NodeId, "/Shared", "value", out UsdAttributeState? rB), + Is.True); + + // Same SdfPath, different stage -> different materialized node instance. + Assert.That(ReferenceEquals(rA, a.AttributesByPath["/Shared.value"]), Is.True); + Assert.That(ReferenceEquals(rB, b.AttributesByPath["/Shared.value"]), Is.True); + Assert.That(ReferenceEquals(rA, rB), Is.False); + } + + [Test] + public void MultiStage_UnknownStage_ResolvesToNull() + { + (_, ushort ns, UsdMaterializationResult a, UsdMaterializationResult b) = TwoStages(); + var all = new[] { a, b }; + + var unknownStage = new NodeId(999999u, ns); + Assert.That( + all.ResolveBindingTargetNodeId(unknownStage, "/Shared", "value").IsNull, Is.True); + Assert.That( + all.TryResolveBindingTarget(unknownStage, "/Shared", "value", out _), Is.False); + } + + // ---- Generic TargetNodeId authoring (ยง10, ยง4.2) -------------------------------- + + [Test] + public void TryAuthorBindingTargetNodeId_AuthorsResolvedNodeId_OnGenericBinding() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + BaseObjectState binding = NewBinding(ms.Namespace); + + NodeId expected = ms.Result.ResolveBindingTargetNodeId("/Shared", "value"); + Assert.That(expected.IsNull, Is.False); + + bool authored = ms.Result.TryAuthorBindingTargetNodeId( + ms.Context, binding, "/Shared", "value", ms.Namespace); + Assert.That(authored, Is.True); + + BaseVariableState? member = FindTargetNodeId(ms.Context, binding, ms.Namespace); + Assert.That(member, Is.Not.Null); + // Authored as a NodeId-typed Variable carrying exactly the resolved node. + Assert.That(member!.DataType, Is.EqualTo(Opc.Ua.DataTypeIds.NodeId)); + Assert.That(member.ValueRank, Is.EqualTo(Opc.Ua.ValueRanks.Scalar)); + Assert.That(member.Value.AsBoxedObject(), Is.EqualTo(expected)); + Assert.That(member.NodeId.IsNull, Is.False); + } + + [Test] + public void TryAuthorBindingTargetNodeId_BogusPath_FailsClosed_AuthorsNothing() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + BaseObjectState binding = NewBinding(ms.Namespace); + + bool authored = ms.Result.TryAuthorBindingTargetNodeId( + ms.Context, binding, "/Nope/DoesNotExist", "bogus", ms.Namespace); + + // A binding must never gain a TargetNodeId naming a node that is not in the address space. + Assert.That(authored, Is.False); + Assert.That(FindTargetNodeId(ms.Context, binding, ms.Namespace), Is.Null); + } + + [Test] + public void TryAuthorBindingTargetNodeId_ReusesExistingMember_Idempotently() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + BaseObjectState binding = NewBinding(ms.Namespace); + + // A generated Part 1 binding already carries a TargetNodeId member; authoring must + // update it in place rather than adding a duplicate. + var preexisting = new PropertyState(binding) + { + BrowseName = new QualifiedName(UsdSceneDiscovery.TargetNodeIdName, ms.Namespace), + DisplayName = new LocalizedText(UsdSceneDiscovery.TargetNodeIdName), + TypeDefinitionId = Opc.Ua.VariableTypeIds.PropertyType, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HasProperty, + DataType = Opc.Ua.DataTypeIds.NodeId, + ValueRank = Opc.Ua.ValueRanks.Scalar, + Value = Variant.From(new NodeId(1234u, ms.Namespace)) + }; + binding.AddChild(preexisting); + preexisting.NodeId = new NodeId(5678u, ms.Namespace); + + bool authored = ms.Result.TryAuthorBindingTargetNodeId( + ms.Context, binding, "/Shared", "value", ms.Namespace); + Assert.That(authored, Is.True); + + List members = + MaterializationHarness.ChildrenOfType(ms.Context, binding) + .Where(v => v.BrowseName.Name == UsdSceneDiscovery.TargetNodeIdName) + .ToList(); + Assert.That(members, Has.Count.EqualTo(1)); + Assert.That(ReferenceEquals(members[0], preexisting), Is.True); + + NodeId expected = ms.Result.ResolveBindingTargetNodeId("/Shared", "value"); + Assert.That(members[0].Value.AsBoxedObject(), Is.EqualTo(expected)); + } + + [Test] + public void TryAuthorBindingTargetNodeId_StageAware_AuthorsForNamedStage() + { + (SystemContext context, ushort ns, UsdMaterializationResult a, UsdMaterializationResult b) = + TwoStages(); + var all = new[] { a, b }; + BaseObjectState binding = NewBinding(ns); + + bool authored = all.TryAuthorBindingTargetNodeId( + context, binding, b.Stage.NodeId, "/Shared", "value", ns); + Assert.That(authored, Is.True); + + BaseVariableState? member = FindTargetNodeId(context, binding, ns); + Assert.That(member, Is.Not.Null); + Assert.That( + member!.Value.AsBoxedObject(), + Is.EqualTo(b.ResolveBindingTargetNodeId("/Shared", "value"))); + } + + [Test] + public void TryAuthorBindingTargetNodeId_StageAware_WrongStage_FailsClosed() + { + (SystemContext context, ushort ns, UsdMaterializationResult a, UsdMaterializationResult b) = + TwoStages(); + var all = new[] { a, b }; + BaseObjectState binding = NewBinding(ns); + + var unknownStage = new NodeId(987654u, ns); + bool authored = all.TryAuthorBindingTargetNodeId( + context, binding, unknownStage, "/Shared", "value", ns); + + Assert.That(authored, Is.False); + Assert.That(FindTargetNodeId(context, binding, ns), Is.Null); + } + + // ---- helpers ------------------------------------------------------------------- + + private static UsdStage NamedStage(string name) + { + var stage = new UsdStage(name) { DefaultPrim = "Shared" }; + var prim = new UsdPrim("Shared", "Xform"); + prim.Attributes.Add(new UsdAttribute("value", "double") { Value = 1.0 }); + stage.AddRootPrim(prim); + return stage; + } + + private static UsdStage SingleStage() + { + return NamedStage("Solo"); + } + + private static (SystemContext Context, ushort Namespace, + UsdMaterializationResult A, UsdMaterializationResult B) TwoStages() + { + // Both stages are materialized in ONE context so their UsdStageType Objects receive + // distinct instance NodeIds โ€” the disambiguation key a real multi-stage Server relies on. + (SystemContext context, ushort ns, BaseObjectState root) = + MaterializationHarness.NewContext(); + UsdMaterializationResult a = context.MaterializeUsdStage(root, NamedStage("StageA"), ns); + UsdMaterializationResult b = context.MaterializeUsdStage(root, NamedStage("StageB"), ns); + return (context, ns, a, b); + } + + private static BaseObjectState NewBinding(ushort ns) + { + return new BaseObjectState(null) + { + NodeId = new NodeId("Binding", ns), + BrowseName = new QualifiedName("Binding", ns), + DisplayName = new LocalizedText("Binding"), + TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType + }; + } + + private static BaseVariableState? FindTargetNodeId( + ISystemContext context, NodeState binding, ushort ns) + { + return binding.FindChild( + context, new QualifiedName(UsdSceneDiscovery.TargetNodeIdName, ns)) as BaseVariableState; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/TestAssets.cs b/tests/Opc.Ua.OpenUsd.Tests/TestAssets.cs new file mode 100644 index 0000000000..6ed9d42e7b --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/TestAssets.cs @@ -0,0 +1,58 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.IO; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Locates and loads the bundled .usda example layers that are copied next to the test + /// assembly. The layers are self-contained so the tests never read from another repository. + /// + internal static class TestAssets + { + /// + /// Gets the directory containing the copied example layers. + /// + public static string Directory => Path.Combine(AppContext.BaseDirectory, "Assets"); + + /// + /// Resolves the full path to a named example layer. + /// + public static string PathTo(string name) => Path.Combine(Directory, name); + + /// + /// Parses a named example layer into a composed stage (example overlays applied). + /// + public static UsdStage Load(string name) => UsdaReader.ParseFile(PathTo(name)); + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/TimeSampleMaterializationTests.cs b/tests/Opc.Ua.OpenUsd.Tests/TimeSampleMaterializationTests.cs new file mode 100644 index 0000000000..b8f0e23030 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/TimeSampleMaterializationTests.cs @@ -0,0 +1,255 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Address-space wiring of USD time samples (ยง7.1 step 3, ยง7.2, ยง9): a sampled attribute + /// materializes its authored default as Value, becomes Historizing with the + /// HistoryRead access bit set, and exposes its ordered samples through the materialization + /// result's surface. The samples + /// round-trip out through the exporter, and the ยง9 time-code โ†’ wall-clock mapping is explicit + /// and fails closed when no epoch is declared. + /// + [TestFixture] + public class TimeSampleMaterializationTests + { + // ---- materialization: default + historizing ------------------------------------ + + [Test] + public void SampledAttribute_MaterializesDefaultAsValue_AndHistorizes() + { + var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; + attr.TimeSamples[0.0] = 0.0; + attr.TimeSamples[24.0] = 90.0; + MaterializedScene ms = MaterializeAttr(attr); + + UsdAttributeState node = ms.Attr("/P.angle"); + // ยง7.1 step 3: the authored default is materialized as the live Value. + Assert.That(node.BoxedValue(), Is.EqualTo(5.0)); + // The samples are the retained timeline, so the node historizes independently of Live. + Assert.That(node.Historizing, Is.True); + Assert.That(node.AccessLevel & Opc.Ua.AccessLevels.CurrentRead, Is.Not.Zero); + Assert.That(node.AccessLevel & Opc.Ua.AccessLevels.HistoryRead, Is.Not.Zero); + } + + [Test] + public void SampledAttribute_WithoutDefault_HasNoValue_ButStillHistorizes() + { + var attr = new UsdAttribute("angle", "double"); + attr.TimeSamples[0.0] = 0.0; + attr.TimeSamples[24.0] = 90.0; + MaterializedScene ms = MaterializeAttr(attr); + + UsdAttributeState node = ms.Attr("/P.angle"); + // No authored default: Value stays unset (fail closed) but the samples still historize. + Assert.That(node.BoxedValue(), Is.Null); + Assert.That(node.Historizing, Is.True); + Assert.That(node.AccessLevel & Opc.Ua.AccessLevels.HistoryRead, Is.Not.Zero); + } + + [Test] + public void HistoricalAccess_ExposesOrderedSamples_KeyedByComposedPath() + { + var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; + attr.TimeSamples[48.0] = 180.0; + attr.TimeSamples[0.0] = 0.0; + attr.TimeSamples[24.0] = 90.0; + MaterializedScene ms = MaterializeAttr(attr); + + Assert.That(ms.Result.HistoricalAccessByPath.ContainsKey("/P.angle"), Is.True); + UsdHistoricalAccess ha = ms.Result.HistoricalAccessByPath["/P.angle"]; + Assert.That(ReferenceEquals(ha.Node, ms.Attr("/P.angle")), Is.True); + Assert.That(ha.AttributePath, Is.EqualTo("/P.angle")); + // Samples are ordered by ascending time code (USD composed sample order). + Assert.That( + ha.Samples.Select(s => s.TimeCode), Is.EqualTo(new[] { 0.0, 24.0, 48.0 })); + Assert.That( + ha.Samples.Select(s => s.Value), Is.EqualTo(new object?[] { 0.0, 90.0, 180.0 })); + } + + [Test] + public void CoauthoredDefaultAndSamples_AreIndependent() + { + var attr = new UsdAttribute("angle", "double") { Value = 42.0 }; + attr.TimeSamples[0.0] = 7.0; + MaterializedScene ms = MaterializeAttr(attr); + + // The default and the first sample differ, proving Value is the default, not sample[0]. + Assert.That(ms.Attr("/P.angle").BoxedValue(), Is.EqualTo(42.0)); + Assert.That( + ms.Result.HistoricalAccessByPath["/P.angle"].Samples.Single().Value, + Is.EqualTo(7.0)); + } + + [Test] + public void NegativeAndFractionalTimeCodes_ArePreserved() + { + var attr = new UsdAttribute("angle", "double"); + attr.TimeSamples[-12.0] = -1.0; + attr.TimeSamples[0.5] = 5.0; + attr.TimeSamples[2.25] = 7.5; + MaterializedScene ms = MaterializeAttr(attr); + + IReadOnlyList samples = + ms.Result.HistoricalAccessByPath["/P.angle"].Samples; + Assert.That(samples.Select(s => s.TimeCode), Is.EqualTo(new[] { -12.0, 0.5, 2.25 })); + Assert.That(samples[0].Value, Is.EqualTo(-1.0)); + } + + [Test] + public void UnknownValueType_Samples_AreHistorized_AndPreservedOpaquely() + { + // ยง8.4: an unrecognized SdfValueTypeName is carried opaquely. Its samples must still be + // recorded verbatim โ€” the materializer never guesses at or drops an unknown value. + var attr = new UsdAttribute("mystery", "customType"); + attr.TimeSamples[0.0] = "opaque-a"; + attr.TimeSamples[10.0] = "opaque-b"; + MaterializedScene ms = MaterializeAttr(attr); + + UsdAttributeState node = ms.Attr("/P.mystery"); + Assert.That(node.Historizing, Is.True); + IReadOnlyList samples = + ms.Result.HistoricalAccessByPath["/P.mystery"].Samples; + Assert.That(samples.Select(s => s.Value), Is.EqualTo(new object?[] { "opaque-a", "opaque-b" })); + } + + // ---- regression: unsampled attributes are untouched ---------------------------- + + [Test] + public void UnsampledAttribute_IsNotHistorizing_AndAbsentFromHistoricalAccess() + { + var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; + MaterializedScene ms = MaterializeAttr(attr); + + Assert.That(ms.Attr("/P.angle").Historizing, Is.False); + Assert.That(ms.Result.HistoricalAccessByPath, Is.Empty); + } + + // ---- ยง9: explicit, fail-closed time-code -> wall-clock mapping ------------------ + + [Test] + public void ResolveUtc_WithoutEpoch_ReturnsNull() + { + var attr = new UsdAttribute("angle", "double"); + attr.TimeSamples[24.0] = 90.0; + // No epoch option and stage declares TimeCodesPerSecond: the timeline is Server-defined. + MaterializedScene ms = MaterializeAttr(attr, tcps: 24.0, epochUtc: null); + + UsdHistoricalAccess ha = ms.Result.HistoricalAccessByPath["/P.angle"]; + Assert.That(ha.EpochUtc, Is.Null); + Assert.That(ha.ResolveUtc(24.0), Is.Null, "no epoch means no wall-clock mapping (ยง9)"); + } + + [Test] + public void ResolveUtc_WithEpochAndTimeCodesPerSecond_MapsToWallClock() + { + var epoch = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var attr = new UsdAttribute("angle", "double"); + attr.TimeSamples[48.0] = 180.0; + MaterializedScene ms = MaterializeAttr(attr, tcps: 24.0, epochUtc: epoch); + + UsdHistoricalAccess ha = ms.Result.HistoricalAccessByPath["/P.angle"]; + Assert.That(ha.EpochUtc, Is.EqualTo(epoch)); + Assert.That(ha.TimeCodesPerSecond, Is.EqualTo(24.0)); + // 48 time codes at 24 codes/second is 2 seconds after the epoch. + Assert.That(ha.ResolveUtc(48.0), Is.EqualTo(epoch.AddSeconds(2.0))); + } + + [Test] + public void ResolveUtc_WithEpochButNoTimeCodesPerSecond_ReturnsNull() + { + var epoch = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var attr = new UsdAttribute("angle", "double"); + attr.TimeSamples[24.0] = 90.0; + // Epoch declared but the stage has no TimeCodesPerSecond: the rate is unknown, so the + // mapping stays undefined rather than assuming a rate (fail closed). + MaterializedScene ms = MaterializeAttr(attr, tcps: null, epochUtc: epoch); + + Assert.That(ms.Result.HistoricalAccessByPath["/P.angle"].ResolveUtc(24.0), Is.Null); + } + + // ---- ยง7.2: samples recovered by the exporter ----------------------------------- + + [Test] + public void SampledAttribute_RoundTripsThroughExport() + { + var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; + attr.TimeSamples[-6.0] = -1.0; + attr.TimeSamples[0.0] = 0.0; + attr.TimeSamples[24.0] = 90.0; + MaterializedScene ms = MaterializeAttr(attr); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + UsdAttribute exportedAttr = exported.Find("/P")!.Attributes.Single(); + + Assert.That(exportedAttr.Value, Is.EqualTo(5.0)); + Assert.That(exportedAttr.TimeSamples.Keys, Is.EqualTo(new[] { -6.0, 0.0, 24.0 })); + Assert.That(exportedAttr.TimeSamples[24.0], Is.EqualTo(90.0)); + } + + [Test] + public void Exporter_WithoutSampleMap_KeepsDefault_ButOmitsSamples() + { + // Exporting from the stage node alone (no samples map) cannot recover the samples โ€” + // they live on the result, not the node โ€” but the authored default still round-trips. + var attr = new UsdAttribute("angle", "double") { Value = 5.0 }; + attr.TimeSamples[0.0] = 0.0; + MaterializedScene ms = MaterializeAttr(attr); + + UsdStage exported = ms.Context.ExportUsdStage(ms.Stage); + UsdAttribute exportedAttr = exported.Find("/P")!.Attributes.Single(); + + Assert.That(exportedAttr.Value, Is.EqualTo(5.0)); + Assert.That(exportedAttr.TimeSamples, Is.Empty); + } + + // ---- helpers ------------------------------------------------------------------- + + private static MaterializedScene MaterializeAttr( + UsdAttribute attribute, double? tcps = null, DateTime? epochUtc = null) + { + var stage = new UsdStage("TS") { DefaultPrim = "P", TimeCodesPerSecond = tcps }; + var prim = new UsdPrim("P", "Xform"); + prim.Attributes.Add(attribute); + stage.AddRootPrim(prim); + + UsdMaterializationOptions? options = + epochUtc == null ? null : new UsdMaterializationOptions { TimeCodeEpochUtc = epochUtc }; + return MaterializationHarness.Materialize(stage, options); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/TypedPrimInstanceTests.cs b/tests/Opc.Ua.OpenUsd.Tests/TypedPrimInstanceTests.cs new file mode 100644 index 0000000000..00f13110d7 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/TypedPrimInstanceTests.cs @@ -0,0 +1,218 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Typed schemas (ยง5.3): a prim of a known typed schema is instantiated as the generated + /// State subclass (so the node is the type it declares and carries the subtype's + /// members and accessors), while an unknown or untyped prim degrades to a concrete + /// UsdPrimType (ยง8.4). The subtype's Optional members are deliberately not pre-created, + /// because ยง7.1 step 3 makes the generic <UsdAttribute> pass authoritative for + /// every attribute value. + /// + [TestFixture] + public class TypedPrimInstanceTests + { + // ---- Known typed schema -> generated State subclass ---------------------------- + + [Test] + [TestCase("Xform", typeof(UsdGeomXformState))] + [TestCase("Scope", typeof(UsdGeomScopeState))] + [TestCase("Mesh", typeof(UsdGeomMeshState))] + [TestCase("Cylinder", typeof(UsdGeomCylinderState))] + [TestCase("Sphere", typeof(UsdGeomSphereState))] + [TestCase("Cube", typeof(UsdGeomCubeState))] + [TestCase("Cone", typeof(UsdGeomConeState))] + [TestCase("Capsule", typeof(UsdGeomCapsuleState))] + [TestCase("Material", typeof(UsdShadeMaterialState))] + [TestCase("Shader", typeof(UsdShadeShaderState))] + public void KnownTypedPrim_InstantiatesGeneratedStateSubclass(string typeName, Type expected) + { + var stage = new UsdStage("Typed") { DefaultPrim = "P" }; + stage.AddRootPrim(new UsdPrim("P", typeName)); + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + UsdPrimState node = ms.Prim("/P"); + // The node's runtime type *is* the generated State subclass, not a retyped UsdPrimState. + Assert.That(node.GetType(), Is.EqualTo(expected)); + // ...and it derives from UsdPrimState, so every prim-typed walk keeps working. + Assert.That(node, Is.InstanceOf()); + // The exact USD token survives and it is indexed for binding resolution. + Assert.That(node.TypeName!.Value, Is.EqualTo(typeName)); + Assert.That(ms.Result.PrimsByPath.ContainsKey("/P"), Is.True); + Assert.That(ReferenceEquals(ms.Result.PrimsByPath["/P"], node), Is.True); + } + + // ---- Browsability: still a HasComponent child with a real NodeId ---------------- + + [Test] + public void KnownTypedPrim_IsBrowsableAsHasComponentChildOfStage() + { + var stage = new UsdStage("Typed") { DefaultPrim = "P" }; + stage.AddRootPrim(new UsdPrim("P", "Mesh")); + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + List stagePrims = + MaterializationHarness.ChildrenOfType(ms.Context, ms.Stage); + Assert.That(stagePrims, Has.Count.EqualTo(1)); + + UsdPrimState node = stagePrims[0]; + Assert.That(node, Is.InstanceOf()); + Assert.That(node.ReferenceTypeId, Is.EqualTo(Opc.Ua.ReferenceTypeIds.HasComponent)); + Assert.That(node.NodeId.IsNull, Is.False); + // The browsed node and the indexed node are the same instance. + Assert.That(ReferenceEquals(node, ms.Prim("/P")), Is.True); + } + + [Test] + public void NestedTypedPrim_IsBrowsableUnderTypedParent() + { + var stage = new UsdStage("Typed") { DefaultPrim = "P" }; + var parent = new UsdPrim("P", "Xform"); + parent.AddChild(new UsdPrim("C", "Sphere")); + stage.AddRootPrim(parent); + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + UsdPrimState parentNode = ms.Prim("/P"); + Assert.That(parentNode, Is.InstanceOf()); + + List childPrims = + MaterializationHarness.ChildrenOfType(ms.Context, parentNode); + Assert.That(childPrims, Has.Count.EqualTo(1)); + Assert.That(childPrims[0], Is.InstanceOf()); + Assert.That(childPrims[0].ReferenceTypeId, Is.EqualTo(Opc.Ua.ReferenceTypeIds.HasComponent)); + Assert.That(ReferenceEquals(childPrims[0], ms.Prim("/P/C")), Is.True); + } + + // ---- ยง8.4 fallback for unknown / untyped prims --------------------------------- + + [Test] + public void UnknownTypedPrim_FallsBackToConcreteUsdPrimType_KeepingToken() + { + var stage = new UsdStage("Vendor") { DefaultPrim = "Thing" }; + stage.AddRootPrim(new UsdPrim("Thing", "MyVendorThing")); + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + UsdPrimState node = ms.Prim("/Thing"); + // Unknown schema degrades to *exactly* UsdPrimState โ€” never a subtype and never an + // abstract type โ€” while keeping its token. + Assert.That(node.GetType(), Is.EqualTo(typeof(UsdPrimState))); + Assert.That( + node.TypeDefinitionId, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.ObjectTypes.UsdPrimType, ms.Namespace))); + Assert.That(node.TypeName!.Value, Is.EqualTo("MyVendorThing")); + } + + [Test] + public void UntypedPrim_FallsBackToConcreteUsdPrimType_WithNoToken() + { + var stage = new UsdStage("U") { DefaultPrim = "P" }; + stage.AddRootPrim(new UsdPrim("P", string.Empty)); + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + UsdPrimState node = ms.Prim("/P"); + Assert.That(node.GetType(), Is.EqualTo(typeof(UsdPrimState))); + Assert.That( + node.TypeDefinitionId, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.ObjectTypes.UsdPrimType, ms.Namespace))); + // An untyped prim authors no TypeName at all. + Assert.That(node.TypeName, Is.Null); + } + + // ---- No duplication between typed members and generic attributes --------------- + + [Test] + public void TypedPrim_DoesNotPreCreateOptionalMembers_SoAttributesStayAuthoritative() + { + var stage = new UsdStage("M") { DefaultPrim = "P" }; + var mesh = new UsdPrim("P", "Mesh"); + // The generic attribute pass authors the value; the typed member "Points" must not + // also be pre-created, or the node would carry the value twice (once empty). + mesh.Attributes.Add(new UsdAttribute("points", "point3f[]")); + stage.AddRootPrim(mesh); + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + UsdPrimState node = ms.Prim("/P"); + Assert.That(node, Is.InstanceOf()); + + // Exactly one authored attribute, the generic lowercase "points". + List attrs = + MaterializationHarness.ChildrenOfType(ms.Context, node); + Assert.That(attrs.Select(a => a.BrowseName.Name), Is.EquivalentTo(new[] { "points" })); + + // The capitalized typed member "Points" is deliberately absent โ€” no duplicate. + var allChildren = new List(); + node.GetChildren(ms.Context, allChildren); + Assert.That(allChildren.Any(c => c.BrowseName.Name == "points"), Is.True); + Assert.That(allChildren.Any(c => c.BrowseName.Name == "Points"), Is.False); + } + + // ---- Exporter still walks every typed prim ------------------------------------- + + [Test] + public void Exporter_FindsEveryTypedPrim_AndRoundTripsTokens() + { + var stage = new UsdStage("Typed") { DefaultPrim = "World" }; + var world = new UsdPrim("World", "Xform"); + world.AddChild(new UsdPrim("Ball", "Sphere")); + world.AddChild(new UsdPrim("Box", "Cube")); + var mat = new UsdPrim("Mat", "Material"); + mat.AddChild(new UsdPrim("Surface", "Shader")); + world.AddChild(mat); + stage.AddRootPrim(world); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + Assert.That(ms.Prim("/World"), Is.InstanceOf()); + Assert.That(ms.Prim("/World/Ball"), Is.InstanceOf()); + Assert.That(ms.Prim("/World/Mat/Surface"), Is.InstanceOf()); + + // The exporter walks prims via ChildrenOfType; the typed subclasses + // derive from UsdPrimState, so every prim must still be found. + UsdStage exported = ms.Context.ExportUsdStage(ms.Stage); + List exportedPaths = exported.AllPrims().Select(p => p.PathOf()).ToList(); + Assert.That( + exportedPaths, + Is.EquivalentTo(new[] + { + "/World", "/World/Ball", "/World/Box", "/World/Mat", "/World/Mat/Surface" + })); + + UsdPrim surface = exported.AllPrims().Single(p => p.Name == "Surface"); + Assert.That(surface.TypeName, Is.EqualTo("Shader")); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdAssetDeliveryTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdAssetDeliveryTests.cs new file mode 100644 index 0000000000..b150f40cb1 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdAssetDeliveryTests.cs @@ -0,0 +1,422 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Text; +using NUnit.Framework; +using Opc.Ua.OpenUsd; + +namespace Opc.Ua.OpenUsd.Server.Tests +{ + /// + /// Unit tests for (spec ยง5.15, conformance unit + /// OU-AssetDelivery): a served USD layer is materialised as an + /// OpenUsdAssetType instance whose Part 5 handlers + /// stream the bytes read-only, hand out independent per-Open cursors, and fail closed + /// for a write-mode Open, a stale handle, or a negative length. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class UsdAssetDeliveryTests + { + private const byte ReadMode = 0x01; + private const byte WriteMode = 0x02; + + [Test] + public void AttachStageAssetsCreatesTheAssetsFolderAndOneAssetPerLayer() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell"); + + ArrayOf created = UsdAssetDelivery.AttachStageAssets( + context, stage, ns, + new ServedAsset[] + { + new("Cell.usda", OpenUsdAssetKindEnum.RootLayer, Bytes("#usda 1.0")), + new("robot.usda", OpenUsdAssetKindEnum.Reference, Bytes("#usda 1.0\ndef Xform {}")) + }); + + Assert.That(created.Count, Is.EqualTo(2)); + Assert.That(stage.Assets, Is.Not.Null); + Assert.That(created[0].AssetIdentifier!.Value, Is.EqualTo("Cell.usda")); + Assert.That(created[0].AssetKind!.Value, Is.EqualTo(OpenUsdAssetKindEnum.RootLayer)); + Assert.That(created[0].MediaType!.Value, Is.EqualTo("model/vnd.usda")); + Assert.That(created[1].AssetIdentifier!.Value, Is.EqualTo("robot.usda")); + Assert.That(created[1].AssetKind!.Value, Is.EqualTo(OpenUsdAssetKindEnum.Reference)); + } + + [Test] + public void AttachStageAssetsReusesAnExistingAssetsFolder() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell"); + + UsdAssetDelivery.AttachStageAssets( + context, stage, ns, new ServedAsset[] { Asset("first.usda") }); + FolderState first = stage.Assets!; + + UsdAssetDelivery.AttachStageAssets( + context, stage, ns, new ServedAsset[] { Asset("second.usda") }); + + Assert.That(ReferenceEquals(stage.Assets, first), Is.True); + } + + [Test] + public void AttachStageAssetsPublishesTheContentDigest() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell"); + byte[] payload = Bytes("#usda 1.0"); + byte[] expected; +#pragma warning disable CA1850 // Prefer static HashData (net48/netstandard2.0 compatibility) + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + expected = sha.ComputeHash(payload); + } +#pragma warning restore CA1850 + + ArrayOf created = UsdAssetDelivery.AttachStageAssets( + context, stage, ns, + new ServedAsset[] { new("Cell.usda", OpenUsdAssetKindEnum.RootLayer, payload) }); + + OpenUsdAssetState asset = created[0]; + Assert.That( + asset.DigestAlgorithm!.Value, Is.EqualTo(OpenUsdDigestAlgorithmEnum.Sha256)); + ByteString digest = asset.Digest!.Value; + Assert.That(digest.IsNull, Is.False); + Assert.That(digest.Length, Is.EqualTo(expected.Length)); + Assert.That(digest.Span[0], Is.EqualTo(expected[0])); + Assert.That(digest.Span[^1], Is.EqualTo(expected[^1])); + } + + [Test] + public void AttachStageAssetsMarksTheFileReadOnlyAndPublishesItsSize() + { + byte[] payload = Bytes("#usda 1.0\n"); + OpenUsdAssetState asset = SingleAsset(payload, out SystemContext _); + + Assert.That(asset.Size!.Value, Is.EqualTo((ulong)payload.Length)); + Assert.That(asset.Writable!.Value, Is.False); + Assert.That(asset.UserWritable!.Value, Is.False); + } + + [Test] + public void AttachStageAssetsCarriesTheSuppliedMediaType() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell"); + + ArrayOf created = UsdAssetDelivery.AttachStageAssets( + context, stage, ns, + new ServedAsset[] + { + new("Cell.usdc", OpenUsdAssetKindEnum.RootLayer, Bytes("binary"), + "model/vnd.usdc") + }); + + Assert.That(created[0].MediaType!.Value, Is.EqualTo("model/vnd.usdc")); + } + + [TestCase("robot.usda", "robot_usda")] + [TestCase("assets/sub dir/tool.usda", "assets_sub_dir_tool_usda")] + [TestCase("_private", "_private")] + [TestCase("3rdParty", "_3rdParty")] + public void AttachStageAssetsSanitisesTheIdentifierIntoABrowseName( + string identifier, string expected) + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell"); + + ArrayOf created = UsdAssetDelivery.AttachStageAssets( + context, stage, ns, new ServedAsset[] { Asset(identifier) }); + + Assert.That(created[0].BrowseName.Name, Is.EqualTo(expected)); + Assert.That(created[0].BrowseName.NamespaceIndex, Is.EqualTo(ns)); + } + + [Test] + public void AttachStageAssetsFallsBackToAConstantBrowseNameForAnEmptyIdentifier() + { + (SystemContext context, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(context, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(context, root, ns, "Cell"); + + ArrayOf created = UsdAssetDelivery.AttachStageAssets( + context, stage, ns, new ServedAsset[] { Asset(string.Empty) }); + + Assert.That(created[0].BrowseName.Name, Is.EqualTo("Asset")); + } + + [Test] + public void OpenThenReadStreamsTheWholePayload() + { + byte[] payload = Bytes("#usda 1.0\ndef Xform \"Root\" {}\n"); + OpenUsdAssetState asset = SingleAsset(payload, out SystemContext context); + + uint handle = 0; + Assert.That( + asset.Open!.OnCall!(context, asset.Open, asset.NodeId, ReadMode, ref handle), + Is.EqualTo(ServiceResult.Good)); + Assert.That(handle, Is.Not.Zero); + + ByteString data = default; + Assert.That( + asset.Read!.OnCall!( + context, asset.Read, asset.NodeId, handle, payload.Length, ref data), + Is.EqualTo(ServiceResult.Good)); + Assert.That(data.Length, Is.EqualTo(payload.Length)); + Assert.That(data.Span[0], Is.EqualTo(payload[0])); + + // A second read past the end returns an empty buffer rather than failing. + ByteString tail = default; + Assert.That( + asset.Read.OnCall!(context, asset.Read, asset.NodeId, handle, 16, ref tail), + Is.EqualTo(ServiceResult.Good)); + Assert.That(tail.Length, Is.Zero); + } + + [Test] + public void OpenRejectsAWriteMode() + { + OpenUsdAssetState asset = SingleAsset(Bytes("payload"), out SystemContext context); + + uint handle = 7; + ServiceResult result = asset.Open!.OnCall!( + context, asset.Open, asset.NodeId, WriteMode | ReadMode, ref handle); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That(handle, Is.Zero); + } + + [Test] + public void OpenRejectsAModeWithoutTheReadBit() + { + OpenUsdAssetState asset = SingleAsset(Bytes("payload"), out SystemContext context); + + uint handle = 7; + ServiceResult result = asset.Open!.OnCall!( + context, asset.Open, asset.NodeId, 0x00, ref handle); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That(handle, Is.Zero); + } + + [Test] + public void OpenRejectsMoreThanTheSupportedNumberOfConcurrentHandles() + { + OpenUsdAssetState asset = SingleAsset(Bytes("payload"), out SystemContext context); + + for (int i = 0; i < 8; i++) + { + uint accepted = 0; + Assert.That( + asset.Open!.OnCall!(context, asset.Open, asset.NodeId, ReadMode, ref accepted), + Is.EqualTo(ServiceResult.Good)); + } + + uint refused = 99; + ServiceResult result = asset.Open!.OnCall!( + context, asset.Open, asset.NodeId, ReadMode, ref refused); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadTooManyOperations)); + Assert.That(refused, Is.Zero); + } + + [Test] + public void ReadRejectsANegativeLength() + { + OpenUsdAssetState asset = SingleAsset(Bytes("payload"), out SystemContext context); + uint handle = OpenHandle(asset, context); + + ByteString data = default; + ServiceResult result = asset.Read!.OnCall!( + context, asset.Read, asset.NodeId, handle, -1, ref data); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void ReadRejectsAnUnknownHandle() + { + OpenUsdAssetState asset = SingleAsset(Bytes("payload"), out SystemContext context); + + ByteString data = default; + ServiceResult result = asset.Read!.OnCall!( + context, asset.Read, asset.NodeId, 4242u, 4, ref data); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); + } + + [Test] + public void CloseInvalidatesTheHandle() + { + OpenUsdAssetState asset = SingleAsset(Bytes("payload"), out SystemContext context); + uint handle = OpenHandle(asset, context); + + Assert.That( + asset.Close!.OnCall!(context, asset.Close, asset.NodeId, handle), + Is.EqualTo(ServiceResult.Good)); + + ByteString data = default; + Assert.That( + asset.Read!.OnCall!(context, asset.Read, asset.NodeId, handle, 4, ref data) + .StatusCode.Code, + Is.EqualTo(StatusCodes.BadInvalidState)); + } + + [Test] + public void CloseIsToleratedForAnUnknownHandle() + { + OpenUsdAssetState asset = SingleAsset(Bytes("payload"), out SystemContext context); + + Assert.That( + asset.Close!.OnCall!(context, asset.Close, asset.NodeId, 4242u), + Is.EqualTo(ServiceResult.Good)); + } + + [Test] + public void GetPositionTracksTheCursorOfItsOwnHandle() + { + byte[] payload = Bytes("0123456789"); + OpenUsdAssetState asset = SingleAsset(payload, out SystemContext context); + uint first = OpenHandle(asset, context); + uint second = OpenHandle(asset, context); + + ByteString data = default; + asset.Read!.OnCall!(context, asset.Read, asset.NodeId, first, 4, ref data); + + ulong firstPosition = 0; + ulong secondPosition = 0; + Assert.That( + asset.GetPosition!.OnCall!( + context, asset.GetPosition, asset.NodeId, first, ref firstPosition), + Is.EqualTo(ServiceResult.Good)); + Assert.That( + asset.GetPosition.OnCall!( + context, asset.GetPosition, asset.NodeId, second, ref secondPosition), + Is.EqualTo(ServiceResult.Good)); + + Assert.That(firstPosition, Is.EqualTo(4UL)); + Assert.That(secondPosition, Is.Zero); + } + + [Test] + public void GetPositionRejectsAnUnknownHandle() + { + OpenUsdAssetState asset = SingleAsset(Bytes("payload"), out SystemContext context); + + ulong position = 0; + ServiceResult result = asset.GetPosition!.OnCall!( + context, asset.GetPosition, asset.NodeId, 4242u, ref position); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); + } + + [Test] + public void SetPositionMovesTheCursor() + { + byte[] payload = Bytes("0123456789"); + OpenUsdAssetState asset = SingleAsset(payload, out SystemContext context); + uint handle = OpenHandle(asset, context); + + Assert.That( + asset.SetPosition!.OnCall!(context, asset.SetPosition, asset.NodeId, handle, 6UL), + Is.EqualTo(ServiceResult.Good)); + + ByteString data = default; + asset.Read!.OnCall!(context, asset.Read, asset.NodeId, handle, 16, ref data); + Assert.That(data.Length, Is.EqualTo(4)); + Assert.That(data.Span[0], Is.EqualTo((byte)'6')); + } + + [Test] + public void SetPositionClampsBeyondTheEndOfTheFile() + { + byte[] payload = Bytes("0123456789"); + OpenUsdAssetState asset = SingleAsset(payload, out SystemContext context); + uint handle = OpenHandle(asset, context); + + Assert.That( + asset.SetPosition!.OnCall!( + context, asset.SetPosition, asset.NodeId, handle, ulong.MaxValue), + Is.EqualTo(ServiceResult.Good)); + + ulong position = 0; + asset.GetPosition!.OnCall!( + context, asset.GetPosition, asset.NodeId, handle, ref position); + Assert.That(position, Is.EqualTo((ulong)payload.Length)); + } + + [Test] + public void SetPositionRejectsAnUnknownHandle() + { + OpenUsdAssetState asset = SingleAsset(Bytes("payload"), out SystemContext context); + + ServiceResult result = asset.SetPosition!.OnCall!( + context, asset.SetPosition, asset.NodeId, 4242u, 0UL); + + Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); + } + + private static byte[] Bytes(string text) + { + return Encoding.UTF8.GetBytes(text); + } + + private static ServedAsset Asset(string identifier) + { + return new ServedAsset(identifier, OpenUsdAssetKindEnum.Reference, Bytes("#usda 1.0")); + } + + private static uint OpenHandle(OpenUsdAssetState asset, SystemContext context) + { + uint handle = 0; + asset.Open!.OnCall!(context, asset.Open, asset.NodeId, ReadMode, ref handle); + return handle; + } + + private static OpenUsdAssetState SingleAsset(byte[] payload, out SystemContext context) + { + (SystemContext created, ushort ns) = OpenUsdAuthoringHarness.NewContext(); + context = created; + OpenUsdRootState root = OpenUsdAuthoringHarness.NewFacility(created, ns); + OpenUsdStageState stage = OpenUsdAuthoringHarness.NewStage(created, root, ns, "Cell"); + return UsdAssetDelivery.AttachStageAssets( + created, stage, ns, + new ServedAsset[] + { + new("Cell.usda", OpenUsdAssetKindEnum.RootLayer, payload) + })[0]; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdFileSinkTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdFileSinkTests.cs new file mode 100644 index 0000000000..f8269d2963 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdFileSinkTests.cs @@ -0,0 +1,275 @@ +/* ======================================================================== + * 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.IO; +using NUnit.Framework; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Unit tests for : USD text authoring, identifier + /// validation, value formatting, composition metadata and batching. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class UsdFileSinkTests + { + private string m_path = string.Empty; + + [SetUp] + public void SetUp() + { + m_path = Path.Combine(Path.GetTempPath(), "usdfilesink-" + Guid.NewGuid().ToString("N") + ".usda"); + } + + [TearDown] + public void TearDown() + { + if (File.Exists(m_path)) + { + File.Delete(m_path); + } + } + + private string ReadLayer() + { + return File.ReadAllText(m_path); + } + + [Test] + public void SetAttributeAuthorsTypedScalarProperty() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("/Pump", "radius", new Variant(2.5)); + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("over \"Pump\"")); + Assert.That(layer, Does.Contain("double radius = 2.5000")); + } + + [Test] + public void SetAttributeDisplayColorAuthorsColor3fArray() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("/Pump", "primvars:displayColor", + new Variant((ArrayOf)[1f, 0f, 0f])); + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("color3f[] primvars:displayColor = [(1.0000, 0.0000, 0.0000)]")); + } + + [Test] + public void SetAttributeNonDisplayColorAuthorsColor3fScalar() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("/Pump", "inputs:emissiveColor", + new Variant((ArrayOf)[0.1f, 1f, 0.2f])); + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("color3f inputs:emissiveColor = (0.1000, 1.0000, 0.2000)")); + } + + [Test] + public void SetAttributeTokenEscapesControlCharacters() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("/Pump", "visibility", new Variant("line1\nline2")); + + string layer = ReadLayer(); + // The newline is escaped so the token value never breaks out of its line. + Assert.That(layer, Does.Contain("token visibility = \"line1\\nline2\"")); + Assert.That(layer, Does.Not.Contain("line1\nline2")); + } + + [Test] + public void NestedPrimPathBuildsHierarchy() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("/Plant/Line1/Pump", "radius", new Variant(1.0)); + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("over \"Plant\"")); + Assert.That(layer, Does.Contain("over \"Line1\"")); + Assert.That(layer, Does.Contain("over \"Pump\"")); + } + + [Test] + public void InvalidPrimPathIsRejectedAndNothingIsWritten() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("1bad/Pump", "radius", new Variant(1.0)); + + Assert.That(File.Exists(m_path), Is.False, "An invalid prim path must not author a layer."); + } + + [Test] + public void PrimPathTraversalSegmentIsRejected() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("/Pump/../Root", "radius", new Variant(1.0)); + + Assert.That(File.Exists(m_path), Is.False, "A '..' path segment must be rejected."); + } + + [Test] + public void InvalidPropertyNameIsRejected() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("/Pump", "bad prop", new Variant(1.0)); + + Assert.That(File.Exists(m_path), Is.False, "An invalid property name must be rejected."); + } + + [Test] + public void ComposePrimReferenceAuthorsPrependReferences() + { + var sink = new UsdFileSink(m_path); + sink.ComposePrim("/Pump", OpenUsdCompositionArc.Reference, "@pump.usda@", active: true); + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("prepend references = @pump.usda@")); + Assert.That(layer, Does.Contain("active = true")); + } + + [Test] + public void ComposePrimPayloadAuthorsPrependPayload() + { + var sink = new UsdFileSink(m_path); + sink.ComposePrim("/Pump", OpenUsdCompositionArc.Payload, "@pump.usda@", active: true); + + Assert.That(ReadLayer(), Does.Contain("prepend payload = @pump.usda@")); + } + + [Test] + public void ComposePrimInstanceAuthorsInstanceable() + { + var sink = new UsdFileSink(m_path); + sink.ComposePrim("/Pump", OpenUsdCompositionArc.Instance, "@pump.usda@", active: true); + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("instanceable = true")); + Assert.That(layer, Does.Contain("prepend references = @pump.usda@")); + } + + [Test] + public void ComposePrimInactiveAuthorsActiveFalse() + { + var sink = new UsdFileSink(m_path); + sink.ComposePrim("/Pump", OpenUsdCompositionArc.Child, assetReference: null, active: false); + + Assert.That(ReadLayer(), Does.Contain("active = false")); + } + + [Test] + public void UnsafeAssetReferenceIsNotAuthored() + { + var sink = new UsdFileSink(m_path); + sink.ComposePrim("/Pump", OpenUsdCompositionArc.Reference, "bad\"ref", active: true); + + string layer = ReadLayer(); + Assert.That(layer, Does.Not.Contain("references")); + // The prim itself is still authored (active state), only the unsafe ref is dropped. + Assert.That(layer, Does.Contain("over \"Pump\"")); + } + + [Test] + public void TimeSamplesAuthorFrameBlock() + { + var sink = new UsdFileSink(m_path); + var t0 = new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc); + var t1 = new DateTime(1970, 1, 1, 0, 0, 2, DateTimeKind.Utc); + using (sink.BeginBatch()) + { + sink.SetTimeSample("/Pump", "radius", t0, new Variant(1.0)); + sink.SetTimeSample("/Pump", "radius", t1, new Variant(2.0)); + } + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("double radius.timeSamples = {")); + Assert.That(layer, Does.Contain("1.000: 1.0000")); + Assert.That(layer, Does.Contain("2.000: 2.0000")); + } + + [Test] + public void SetAttributeVectorAuthorsDouble3Property() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("/Pump", "xformOp:translate", + new Variant((ArrayOf)[1.0, 2.0, 3.0])); + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("double3 xformOp:translate = (1.0000, 2.0000, 3.0000)")); + } + + [Test] + public void SetAttributeVectorWithMoreThanThreeElementsUsesFirstThree() + { + var sink = new UsdFileSink(m_path); + sink.SetAttribute("/Pump", "xformOp:translate", + new Variant((ArrayOf)[1.0, 2.0, 3.0, 4.0])); + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("double3 xformOp:translate = (1.0000, 2.0000, 3.0000)")); + } + + [Test] + public void TimeSamplesAuthorDouble3FrameBlock() + { + var sink = new UsdFileSink(m_path); + var t0 = new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc); + var t1 = new DateTime(1970, 1, 1, 0, 0, 2, DateTimeKind.Utc); + using (sink.BeginBatch()) + { + sink.SetTimeSample("/Pump", "xformOp:translate", t0, + new Variant((ArrayOf)[1.0, 2.0, 3.0])); + sink.SetTimeSample("/Pump", "xformOp:translate", t1, + new Variant((ArrayOf)[4.0, 5.0, 6.0])); + } + + string layer = ReadLayer(); + Assert.That(layer, Does.Contain("double3 xformOp:translate.timeSamples = {")); + Assert.That(layer, Does.Contain("1.000: (1.0000, 2.0000, 3.0000)")); + Assert.That(layer, Does.Contain("2.000: (4.0000, 5.0000, 6.0000)")); + } + + [Test] + public void BatchDefersWriteUntilScopeDisposed() + { + var sink = new UsdFileSink(m_path); + IDisposable batch = sink.BeginBatch(); + sink.SetAttribute("/Pump", "radius", new Variant(1.0)); + + Assert.That(File.Exists(m_path), Is.False, "Writes must be deferred while a batch is open."); + batch.Dispose(); + Assert.That(File.Exists(m_path), Is.True, "Closing the batch must flush the layer once."); + Assert.That(ReadLayer(), Does.Contain("double radius = 1.0000")); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdFileSinkValidationTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdFileSinkValidationTests.cs new file mode 100644 index 0000000000..9973fb132f --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdFileSinkValidationTests.cs @@ -0,0 +1,160 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.IO; +using NUnit.Framework; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Unit tests for the defensive edges of : every write path + /// silently drops a prim path or property name that is not a valid USD identifier chain, + /// the token escaper neutralises every character that could break out of a quoted token, + /// and a value of an unmapped kind degrades to a typed zero rather than producing invalid + /// USD text. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class UsdFileSinkValidationTests + { + private string m_path = string.Empty; + + [SetUp] + public void SetUp() + { + m_path = Path.Combine( + Path.GetTempPath(), "usdfilesink-validation-" + Guid.NewGuid().ToString("N") + ".usda"); + } + + [TearDown] + public void TearDown() + { + if (File.Exists(m_path)) + { + File.Delete(m_path); + } + } + + [TestCase("")] + [TestCase("/")] + [TestCase("///")] + [TestCase("/9Pump")] + [TestCase("/Pump/../Secret")] + [TestCase("/Pump Body")] + public void ComposePrimRejectsAnInvalidPrimPath(string primPath) + { + var sink = new UsdFileSink(m_path); + + sink.ComposePrim(primPath, OpenUsdCompositionArc.Reference, "@pump.usda@", true); + + Assert.That(File.Exists(m_path), Is.False); + } + + [Test] + public void ComposePrimAcceptsAValidPrimPath() + { + var sink = new UsdFileSink(m_path); + + sink.ComposePrim("/Pump", OpenUsdCompositionArc.Reference, "@pump.usda@", true); + + Assert.That(File.ReadAllText(m_path), Does.Contain("prepend references")); + } + + [TestCase("")] + [TestCase("/")] + [TestCase("/Pump/../Secret")] + public void SetTimeSampleRejectsAnInvalidPrimPath(string primPath) + { + var sink = new UsdFileSink(m_path); + + sink.SetTimeSample(primPath, "radius", DateTime.UtcNow, new Variant(1.0)); + + Assert.That(File.Exists(m_path), Is.False); + } + + [TestCase("")] + [TestCase("xformOp::translate")] + [TestCase("2fast")] + [TestCase("has space")] + public void SetTimeSampleRejectsAnInvalidPropertyName(string propertyName) + { + var sink = new UsdFileSink(m_path); + + sink.SetTimeSample("/Pump", propertyName, DateTime.UtcNow, new Variant(1.0)); + + Assert.That(File.Exists(m_path), Is.False); + } + + [Test] + public void SetTimeSampleAcceptsANamespacedPropertyName() + { + var sink = new UsdFileSink(m_path); + + sink.SetTimeSample( + "/Pump", "xformOp:translate", new DateTime(2026, 1, 1, 0, 0, 1, DateTimeKind.Utc), + new Variant(4.0)); + + Assert.That(File.ReadAllText(m_path), Does.Contain("xformOp:translate.timeSamples")); + } + + [Test] + public void SetAttributeRejectsAnEmptyPropertyName() + { + var sink = new UsdFileSink(m_path); + + sink.SetAttribute("/Pump", string.Empty, new Variant(1.0)); + + Assert.That(File.Exists(m_path), Is.False); + } + + [Test] + public void SetAttributeEscapesEveryTokenBreakoutCharacter() + { + var sink = new UsdFileSink(m_path); + + sink.SetAttribute( + "/Pump", "visibility", new Variant("a\\b\"c\rd\te")); + + string layer = File.ReadAllText(m_path); + Assert.That(layer, Does.Contain("token visibility = \"a\\\\b\\\"c\\rd\\te\"")); + Assert.That(layer, Does.Not.Contain("\t")); + } + + [Test] + public void SetAttributeWritesATypedZeroForAnUnmappedValueKind() + { + var sink = new UsdFileSink(m_path); + + sink.SetAttribute("/Pump", "enabled", new Variant(true)); + + Assert.That(File.ReadAllText(m_path), Does.Contain("double enabled = 0.0000")); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneDiscoveryTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneDiscoveryTests.cs new file mode 100644 index 0000000000..1a21a6abca --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneDiscoveryTests.cs @@ -0,0 +1,223 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Fail-fast argument validation and folder-reuse behaviour of + /// . Every discovery helper is an extension method, so a + /// null receiver reaches the body instead of throwing at the call site: each one + /// must reject it explicitly rather than surface a . + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class UsdSceneDiscoveryTests + { + [Test] + public void EnsureStagesFolderRejectsANullContext() + { + (_, ushort ns, BaseObjectState server) = MaterializationHarness.NewContext(); + + Assert.That( + () => ((ISystemContext)null!).EnsureStagesFolder(server, ns), + Throws.ArgumentNullException); + } + + [Test] + public void EnsureStagesFolderRejectsANullServer() + { + (SystemContext context, ushort ns, _) = MaterializationHarness.NewContext(); + + Assert.That( + () => context.EnsureStagesFolder(null!, ns), + Throws.ArgumentNullException); + } + + [Test] + public void EnsureStagesFolderSkipsUnrelatedSiblingFolders() + { + (SystemContext context, ushort ns, BaseObjectState server) = + MaterializationHarness.NewContext(); + var unrelated = new FolderState(server) + { + BrowseName = new QualifiedName("SomethingElse", ns), + DisplayName = new LocalizedText("SomethingElse"), + TypeDefinitionId = Opc.Ua.ObjectTypeIds.FolderType, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HasComponent + }; + server.AddChild(unrelated); + + FolderState stages = context.EnsureStagesFolder(server, ns); + + Assert.That(stages.BrowseName.Name, Is.EqualTo(UsdSceneDiscovery.StagesFolderName)); + List roots = + MaterializationHarness.ChildrenOfType(context, server); + Assert.That(roots, Has.Count.EqualTo(2)); + Assert.That( + roots.Count(f => f.BrowseName.Name == UsdSceneDiscovery.OpenUsdSceneRootName), + Is.EqualTo(1)); + } + + [Test] + public void TryResolveBindingTargetRejectsANullResult() + { + Assert.That( + () => ((UsdMaterializationResult)null!) + .TryResolveBindingTarget("/Shared", "value", out _), + Throws.ArgumentNullException); + } + + [Test] + public void StageAwareTryResolveBindingTargetRejectsANullResult() + { + Assert.That( + () => ((UsdMaterializationResult)null!) + .TryResolveBindingTarget(new NodeId(1u, 1), "/Shared", "value", out _), + Throws.ArgumentNullException); + } + + [Test] + public void MultiStageTryResolveBindingTargetRejectsANullSequence() + { + Assert.That( + () => ((IEnumerable)null!) + .TryResolveBindingTarget(new NodeId(1u, 1), "/Shared", "value", out _), + Throws.ArgumentNullException); + } + + [Test] + public void MultiStageTryResolveBindingTargetSkipsNullEntries() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + var results = new UsdMaterializationResult?[] { null, ms.Result }; + + bool resolved = results!.TryResolveBindingTarget( + ms.Stage.NodeId, "/Shared", "value", out UsdAttributeState? attribute); + + Assert.That(resolved, Is.True); + Assert.That(attribute, Is.Not.Null); + } + + [Test] + public void TryAuthorBindingTargetNodeIdRejectsANullResult() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + + Assert.That( + () => ((UsdMaterializationResult)null!).TryAuthorBindingTargetNodeId( + ms.Context, NewBinding(ms.Namespace), "/Shared", "value", ms.Namespace), + Throws.ArgumentNullException); + } + + [Test] + public void TryAuthorBindingTargetNodeIdRejectsANullContext() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + + Assert.That( + () => ms.Result.TryAuthorBindingTargetNodeId( + null!, NewBinding(ms.Namespace), "/Shared", "value", ms.Namespace), + Throws.ArgumentNullException); + } + + [Test] + public void TryAuthorBindingTargetNodeIdRejectsANullBinding() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + + Assert.That( + () => ms.Result.TryAuthorBindingTargetNodeId( + ms.Context, null!, "/Shared", "value", ms.Namespace), + Throws.ArgumentNullException); + } + + [Test] + public void StageAwareTryAuthorBindingTargetNodeIdRejectsANullSequence() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + + Assert.That( + () => ((IEnumerable)null!).TryAuthorBindingTargetNodeId( + ms.Context, NewBinding(ms.Namespace), ms.Stage.NodeId, + "/Shared", "value", ms.Namespace), + Throws.ArgumentNullException); + } + + [Test] + public void StageAwareTryAuthorBindingTargetNodeIdRejectsANullContext() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + var results = new[] { ms.Result }; + + Assert.That( + () => results.TryAuthorBindingTargetNodeId( + null!, NewBinding(ms.Namespace), ms.Stage.NodeId, + "/Shared", "value", ms.Namespace), + Throws.ArgumentNullException); + } + + [Test] + public void StageAwareTryAuthorBindingTargetNodeIdRejectsANullBinding() + { + MaterializedScene ms = MaterializationHarness.Materialize(SingleStage()); + var results = new[] { ms.Result }; + + Assert.That( + () => results.TryAuthorBindingTargetNodeId( + ms.Context, null!, ms.Stage.NodeId, "/Shared", "value", ms.Namespace), + Throws.ArgumentNullException); + } + + private static UsdStage SingleStage() + { + var stage = new UsdStage("Solo") { DefaultPrim = "Shared" }; + var prim = new UsdPrim("Shared", "Xform"); + prim.Attributes.Add(new UsdAttribute("value", "double") { Value = 1.0 }); + stage.AddRootPrim(prim); + return stage; + } + + private static BaseObjectState NewBinding(ushort ns) + { + return new BaseObjectState(null) + { + NodeId = new NodeId("Binding", ns), + BrowseName = new QualifiedName("Binding", ns), + DisplayName = new LocalizedText("Binding"), + TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType + }; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneExporterFallbackTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneExporterFallbackTests.cs new file mode 100644 index 0000000000..b199bfac78 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneExporterFallbackTests.cs @@ -0,0 +1,204 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Unit tests for the compatibility fallbacks of the exporter: a stage whose attributes + /// carry no ConnectionPaths member โ€” because it was materialized by an older build + /// or by a foreign server โ€” must still export its connections, first from the recorded side + /// channel and, failing that, from the browsable UsdConnection edges. The exporter + /// also has to fail fast on a missing argument and tolerate members a foreign address space + /// simply does not have. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class UsdSceneExporterFallbackTests + { + [Test] + public void ExportUsdStageRejectsANullResult() + { + (SystemContext context, ushort _, BaseObjectState _) = + MaterializationHarness.NewContext(); + UsdMaterializationResult result = null!; + + Assert.That(() => context.ExportUsdStage(result), Throws.ArgumentNullException); + } + + [Test] + public void ExportUsdStageRejectsANullContext() + { + MaterializedScene scene = MaterializationHarness.Materialize(ConnectedScene()); + ISystemContext context = null!; + + Assert.That( + () => context.ExportUsdStage(scene.Stage), Throws.ArgumentNullException); + } + + [Test] + public void ExportUsdStageRejectsANullStageNode() + { + (SystemContext context, ushort _, BaseObjectState _) = + MaterializationHarness.NewContext(); + UsdStageState stageNode = null!; + + Assert.That(() => context.ExportUsdStage(stageNode), Throws.ArgumentNullException); + } + + [Test] + public void ConnectionsAreRecoveredFromTheRecordedSideChannel() + { + MaterializedScene scene = MaterializationHarness.Materialize(ConnectedScene()); + DropConnectionPaths(scene); + + UsdStage exported = scene.Context.ExportUsdStage(scene.Result); + + UsdAttribute radius = ExportedAttribute(exported, "radius"); + Assert.That(radius.Connections, Has.Count.EqualTo(2)); + Assert.That(radius.Connections[0], Is.EqualTo("/Mesh.size")); + Assert.That( + radius.Connections[1], + Is.EqualTo("/Elsewhere.size"), + "The recorded side channel keeps an out-of-subtree target."); + } + + [Test] + public void ConnectionsAreRebuiltFromTheBrowsableEdgesForABareStageNode() + { + MaterializedScene scene = MaterializationHarness.Materialize(ConnectedScene()); + DropConnectionPaths(scene); + + UsdStage exported = scene.Context.ExportUsdStage(scene.Stage); + + UsdAttribute radius = ExportedAttribute(exported, "radius"); + Assert.That( + radius.Connections, + Has.Count.EqualTo(1), + "Only the in-subtree target has a browsable edge to rebuild from."); + Assert.That(radius.Connections[0], Does.StartWith("/Mesh.")); + } + + [Test] + public void AMetadataEntryWithoutABrowseNameIsSkipped() + { + var prim = new UsdPrim("Mesh", "Mesh"); + prim.Metadata["author"] = "Ada"; + var stage = new UsdStage("Test"); + stage.AddRootPrim(prim); + MaterializedScene scene = MaterializationHarness.Materialize(stage); + + FolderState metadata = scene.Prim("/Mesh").Metadata!; + var nameless = new PropertyState(metadata) + { + NodeId = new NodeId(90210u, scene.Namespace), + BrowseName = new QualifiedName(string.Empty, scene.Namespace), + DisplayName = new LocalizedText("nameless"), + TypeDefinitionId = Opc.Ua.VariableTypeIds.PropertyType, + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HasProperty, + DataType = Opc.Ua.DataTypeIds.String, + ValueRank = ValueRanks.Scalar, + Value = new Variant("ignored") + }; + metadata.AddChild(nameless); + + UsdStage exported = scene.Context.ExportUsdStage(scene.Result); + + UsdPrim exportedPrim = exported.RootPrims[0]; + Assert.That(exportedPrim.Metadata, Has.Count.EqualTo(1)); + Assert.That(exportedPrim.Metadata.ContainsKey("author"), Is.True); + } + + [Test] + public void AMissingEnumMemberFallsBackToItsDefault() + { + MaterializedScene scene = MaterializationHarness.Materialize(ConnectedScene()); + UsdPrimState primNode = scene.Prim("/Mesh"); + primNode.Kind = null; + + UsdStage exported = scene.Context.ExportUsdStage(scene.Result); + + Assert.That(exported.RootPrims[0].Kind, Is.EqualTo(UsdPrimKindEnum.Unspecified)); + } + + [Test] + public void AnEnumMemberHoldingAnUnreadableValueFallsBackToItsDefault() + { + MaterializedScene scene = MaterializationHarness.Materialize(ConnectedScene()); + UsdPrimState primNode = scene.Prim("/Mesh"); + primNode.Kind!.WrappedValue = new Variant("not-an-enum"); + + UsdStage exported = scene.Context.ExportUsdStage(scene.Result); + + Assert.That(exported.RootPrims[0].Kind, Is.EqualTo(UsdPrimKindEnum.Unspecified)); + } + + private static UsdStage ConnectedScene() + { + var prim = new UsdPrim("Mesh", "Mesh") { Kind = UsdPrimKindEnum.Component }; + prim.Attributes.Add(new UsdAttribute("size", "double") { Value = 2.0 }); + var radius = new UsdAttribute("radius", "double") { Value = 1.0 }; + radius.Connections.Add("/Mesh.size"); + radius.Connections.Add("/Elsewhere.size"); + prim.Attributes.Add(radius); + + var stage = new UsdStage("Test"); + stage.AddRootPrim(prim); + return stage; + } + + private static void DropConnectionPaths(MaterializedScene scene) + { + foreach (KeyValuePair entry in scene.Result.AttributesByPath) + { + entry.Value.ConnectionPaths = null; + } + } + + private static UsdAttribute ExportedAttribute(UsdStage stage, string name) + { + foreach (UsdPrim prim in stage.AllPrims()) + { + foreach (UsdAttribute attribute in prim.Attributes) + { + if (attribute.Name == name) + { + return attribute; + } + } + } + Assert.Fail("The exported scene does not carry an attribute named " + name + "."); + return null!; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs new file mode 100644 index 0000000000..d0ff93d1a9 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdSceneSignatureTests.cs @@ -0,0 +1,138 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + [TestFixture] + public class UsdSceneSignatureTests + { + [Test] + public void Signature_ExcludesNonComposedState() + { + string bare = UsdSceneSignature.Compute(BuildStage(withNonComposedState: false)); + string decorated = UsdSceneSignature.Compute(BuildStage(withNonComposedState: true)); + + Assert.That(decorated, Is.EqualTo(bare)); + Assert.That(UsdSceneSignature.FirstDifference( + BuildStage(withNonComposedState: false), + BuildStage(withNonComposedState: true)), Is.Null); + } + + [Test] + public void Signature_ExcludesStageLevelMetadata() + { + var a = new UsdStage("A") { DefaultPrim = "X", UpAxis = "Y", MetersPerUnit = 1.0 }; + a.AddRootPrim(new UsdPrim("X", "Xform")); + + var b = new UsdStage("B") { DefaultPrim = "Other", UpAxis = "Z", MetersPerUnit = 0.01 }; + b.AddRootPrim(new UsdPrim("X", "Xform")); + + Assert.That(UsdSceneSignature.Compute(b), Is.EqualTo(UsdSceneSignature.Compute(a))); + } + + [Test] + public void Signature_DetectsChangedAttributeValue() + { + var a = new UsdStage("S"); + UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); + pa.Attributes.Add(new UsdAttribute("v", "int") { Value = 1L }); + + var b = new UsdStage("S"); + UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); + pb.Attributes.Add(new UsdAttribute("v", "int") { Value = 2L }); + + Assert.That(UsdSceneSignature.Compute(b), Is.Not.EqualTo(UsdSceneSignature.Compute(a))); + Assert.That(UsdSceneSignature.FirstDifference(a, b), Is.Not.Null); + } + + [Test] + public void Signature_IsIndependentOfAttributeOrder() + { + var a = new UsdStage("S"); + UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); + pa.Attributes.Add(new UsdAttribute("a", "int") { Value = 1L }); + pa.Attributes.Add(new UsdAttribute("b", "int") { Value = 2L }); + + var b = new UsdStage("S"); + UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); + pb.Attributes.Add(new UsdAttribute("b", "int") { Value = 2L }); + pb.Attributes.Add(new UsdAttribute("a", "int") { Value = 1L }); + + Assert.That(UsdSceneSignature.Compute(b), Is.EqualTo(UsdSceneSignature.Compute(a))); + } + + [Test] + public void Signature_IsSensitiveToCompositionArcOrder() + { + var a = new UsdStage("S"); + UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); + pa.Composition.Add(new UsdCompositionArc(UsdArcKindEnum.Reference) { AssetPath = "r.usda", PrimPath = "/R" }); + pa.Composition.Add(new UsdCompositionArc(UsdArcKindEnum.Instance) { AssetPath = "r.usda", PrimPath = "/R" }); + + var b = new UsdStage("S"); + UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); + pb.Composition.Add(new UsdCompositionArc(UsdArcKindEnum.Instance) { AssetPath = "r.usda", PrimPath = "/R" }); + pb.Composition.Add(new UsdCompositionArc(UsdArcKindEnum.Reference) { AssetPath = "r.usda", PrimPath = "/R" }); + + Assert.That(UsdSceneSignature.Compute(b), Is.Not.EqualTo(UsdSceneSignature.Compute(a))); + } + + private static UsdStage BuildStage(bool withNonComposedState) + { + var stage = new UsdStage("S") + { + Documentation = "stage doc is ignored", + }; + var prim = new UsdPrim("X", "Xform") + { + Kind = UsdPrimKindEnum.Component, + Active = withNonComposedState, + Instanceable = withNonComposedState, + }; + var attr = new UsdAttribute("v", "int") + { + Value = 1L, + Live = withNonComposedState, + Interpolation = withNonComposedState ? "vertex" : null, + }; + prim.Attributes.Add(attr); + if (withNonComposedState) + { + prim.ApiSchemas.Add(new UsdApiSchema("MaterialBindingAPI")); + prim.Metadata["hidden"] = true; + } + stage.AddRootPrim(prim); + return stage; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdTestHelpers.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdTestHelpers.cs new file mode 100644 index 0000000000..6f63f624af --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdTestHelpers.cs @@ -0,0 +1,66 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Small assertion helpers that resolve a required prim, attribute or relationship and fail the + /// test with a readable message when it is missing. + /// + internal static class UsdTestHelpers + { + public static UsdPrim RequirePrim(UsdStage stage, string path) + { + UsdPrim? prim = stage.Find(path); + Assert.That(prim, Is.Not.Null, "expected a prim at " + path); + return prim!; + } + + public static UsdAttribute RequireAttribute(UsdPrim prim, string name) + { + UsdAttribute? attr = prim.Attributes + .FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.Ordinal)); + Assert.That(attr, Is.Not.Null, "expected attribute " + name + " on " + prim.Path); + return attr!; + } + + public static UsdRelationship RequireRelationship(UsdPrim prim, string name) + { + UsdRelationship? rel = prim.Relationships + .FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.Ordinal)); + Assert.That(rel, Is.Not.Null, "expected relationship " + name + " on " + prim.Path); + return rel!; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleEqualityTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleEqualityTests.cs new file mode 100644 index 0000000000..20e1096ba7 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleEqualityTests.cs @@ -0,0 +1,114 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Unit tests for the value semantics of . Samples are compared + /// by (time code, value) so a recorded timeline can be diffed against an expected one, and + /// the hash is keyed on the time code alone so samples that compare equal always hash equal. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class UsdTimeSampleEqualityTests + { + [Test] + public void SamplesWithTheSameTimeCodeAndValueAreEqual() + { + var left = new UsdTimeSample(1.5, 42.0); + var right = new UsdTimeSample(1.5, 42.0); + bool viaOperator = left == right; + bool viaInequality = left != right; + + Assert.That(left, Is.EqualTo(right)); + Assert.That(viaOperator, Is.True); + Assert.That(viaInequality, Is.False); + Assert.That(left.GetHashCode(), Is.EqualTo(right.GetHashCode())); + } + + [Test] + public void SamplesWithADifferentTimeCodeAreNotEqual() + { + var left = new UsdTimeSample(1.5, 42.0); + var right = new UsdTimeSample(2.5, 42.0); + bool viaOperator = left == right; + bool viaInequality = left != right; + + Assert.That(left, Is.Not.EqualTo(right)); + Assert.That(viaOperator, Is.False); + Assert.That(viaInequality, Is.True); + } + + [Test] + public void SamplesWithADifferentValueAreNotEqual() + { + var left = new UsdTimeSample(1.5, 42.0); + var right = new UsdTimeSample(1.5, "42"); + bool viaEquatable = left.Equals(right); + + Assert.That(viaEquatable, Is.False); + Assert.That(left, Is.Not.EqualTo(right)); + } + + [Test] + public void ASampleWithoutAValueEqualsAnotherWithoutAValue() + { + var left = new UsdTimeSample(-3.25, null); + var right = new UsdTimeSample(-3.25, null); + bool viaEquatable = left.Equals(right); + + Assert.That(viaEquatable, Is.True); + Assert.That(left.GetHashCode(), Is.EqualTo(right.GetHashCode())); + } + + [Test] + public void ASampleEqualsABoxedSampleWithTheSameContent() + { + var sample = new UsdTimeSample(0.5, 7); + object boxed = new UsdTimeSample(0.5, 7); + bool equal = sample.Equals(boxed); + + Assert.That(equal, Is.True); + } + + [Test] + public void ASampleDoesNotEqualAnObjectOfAnotherType() + { + var sample = new UsdTimeSample(0.5, 7); + bool equalsText = sample.Equals("0.5"); + bool equalsNothing = sample.Equals(null); + + Assert.That(equalsText, Is.False); + Assert.That(equalsNothing, Is.False); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleTests.cs new file mode 100644 index 0000000000..af1f90889d --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdTimeSampleTests.cs @@ -0,0 +1,392 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Tests for USD time-sample support in the conversion layer (spec ยง7.1 step 3, ยง7.2, ยง9): + /// the reader parses a .timeSamples = { โ€ฆ } block into an ordered map kept separate + /// from the authored default, the writer re-emits it, and the ยง7.4 signature preserves it. + /// + [TestFixture] + public class UsdTimeSampleTests + { + private static string Wrap(params string[] bodyLines) + { + var lines = new List + { + "#usda 1.0", + "(", + " defaultPrim = \"P\"", + " metersPerUnit = 1.0", + " upAxis = \"Z\"", + ")", + string.Empty, + "def Xform \"P\"", + "{", + }; + lines.AddRange(bodyLines); + lines.Add("}"); + lines.Add(string.Empty); + return string.Join("\n", lines); + } + + private static UsdAttribute ParseSingleAttribute(string stageName, params string[] bodyLines) + { + UsdStage stage = UsdaReader.Parse(Wrap(bodyLines), stageName); + UsdPrim prim = stage.Find("/P")!; + return prim.Attributes[0]; + } + + private static void AssertRoundTripSignature(UsdStage expected) + { + string written = UsdaWriter.Write(expected); + UsdStage reparsed = UsdaReader.Parse(written, expected.StageName); + + string? difference = UsdSceneSignature.FirstDifference(expected, reparsed); + Assert.That( + UsdSceneSignature.Compute(reparsed), + Is.EqualTo(UsdSceneSignature.Compute(expected)), + difference ?? "signatures are unexpectedly equal"); + } + + // ---- parsing ------------------------------------------------------------------ + + [Test] + public void MultiLineBlock_ParsesScalarSamples_SeparateFromDefault() + { + UsdAttribute attr = ParseSingleAttribute( + "TS", + " double xformOp:rotateZ.timeSamples = {", + " 0: 0.0,", + " 24: 90.0,", + " 48: 180.0,", + " }"); + + Assert.That(attr.Value, Is.Null, "no authored default was declared"); + Assert.That(attr.TimeSamples.Keys, Is.EqualTo(new[] { 0.0, 24.0, 48.0 })); + Assert.That(attr.TimeSamples.Values, Is.EqualTo(new object?[] { 0.0, 90.0, 180.0 })); + } + + [Test] + public void SingleLineBlock_ParsesSamples() + { + UsdAttribute attr = ParseSingleAttribute( + "TS", + " double a.timeSamples = { 0: 0.0, 24: 90.0 }"); + + Assert.That(attr.TimeSamples.Keys, Is.EqualTo(new[] { 0.0, 24.0 })); + Assert.That(attr.TimeSamples.Values, Is.EqualTo(new object?[] { 0.0, 90.0 })); + } + + [Test] + public void NegativeAndFractionalTimeCodes_AreParsed() + { + UsdAttribute attr = ParseSingleAttribute( + "TS", + " double a.timeSamples = {", + " -12: -1.0,", + " 0.5: 5.0,", + " 2.25: 7.5,", + " }"); + + Assert.That(attr.TimeSamples.Keys, Is.EqualTo(new[] { -12.0, 0.5, 2.25 })); + Assert.That(attr.TimeSamples[-12.0], Is.EqualTo(-1.0)); + Assert.That(attr.TimeSamples[0.5], Is.EqualTo(5.0)); + } + + [Test] + public void SamplesAreOrderedByTimeCode_EvenWhenAuthoredOutOfOrder() + { + UsdAttribute attr = ParseSingleAttribute( + "TS", + " double a.timeSamples = {", + " 48: 180.0,", + " 0: 0.0,", + " 24: 90.0,", + " }"); + + Assert.That(attr.TimeSamples.Keys, Is.EqualTo(new[] { 0.0, 24.0, 48.0 })); + } + + [Test] + public void TupleValuedSamples_AreParsed() + { + UsdAttribute attr = ParseSingleAttribute( + "TS", + " float3 xformOp:translate.timeSamples = {", + " 0: (0, 0, 0),", + " 24: (1, 2, 3),", + " }"); + + Assert.That(attr.TimeSamples[0.0], Is.EqualTo(new object?[] { 0L, 0L, 0L })); + Assert.That(attr.TimeSamples[24.0], Is.EqualTo(new object?[] { 1L, 2L, 3L })); + } + + [Test] + public void ArrayValuedSamples_AreParsed() + { + UsdAttribute attr = ParseSingleAttribute( + "TS", + " int[] a.timeSamples = {", + " 0: [1, 2, 3],", + " 24: [4, 5, 6],", + " }"); + + Assert.That(attr.TimeSamples[0.0], Is.EqualTo(new List { 1L, 2L, 3L })); + Assert.That(attr.TimeSamples[24.0], Is.EqualTo(new List { 4L, 5L, 6L })); + } + + [Test] + public void AssetValuedSamples_AreUnwrapped() + { + UsdAttribute attr = ParseSingleAttribute( + "TS", + " asset a.timeSamples = {", + " 0: @./a.usda@,", + " 24: @./b.usda@,", + " }"); + + Assert.That(attr.TimeSamples[0.0], Is.EqualTo("./a.usda")); + Assert.That(attr.TimeSamples[24.0], Is.EqualTo("./b.usda")); + } + + [Test] + public void TokenValuedSamples_AreUnwrapped() + { + UsdAttribute attr = ParseSingleAttribute( + "TS", + " token door:state.timeSamples = {", + " 0: \"open\",", + " 24: \"closed\",", + " }"); + + Assert.That(attr.TimeSamples[0.0], Is.EqualTo("open")); + Assert.That(attr.TimeSamples[24.0], Is.EqualTo("closed")); + } + + [Test] + public void DefaultAndSamples_CoexistOnOneAttribute() + { + UsdAttribute attr = ParseSingleAttribute( + "TS", + " double a = 5.0", + " double a.timeSamples = {", + " 0: 0.0,", + " 24: 90.0,", + " }"); + + Assert.That(attr.Value, Is.EqualTo(5.0)); + Assert.That(attr.TimeSamples.Keys, Is.EqualTo(new[] { 0.0, 24.0 })); + } + + [Test] + public void SamplesOnlyAttribute_HasNoBareDefaultDeclaration() + { + // An attribute declared only through its .timeSamples must not also gain a spurious + // valueless declaration that would re-parse to a second attribute. + UsdStage stage = UsdaReader.Parse( + Wrap( + " double a.timeSamples = {", + " 0: 0.0,", + " }"), + "TS"); + + UsdPrim prim = stage.Find("/P")!; + Assert.That(prim.Attributes.Count(a => a.Name == "a"), Is.EqualTo(1)); + } + + // ---- round-trip --------------------------------------------------------------- + + [Test] + public void ScalarSamples_RoundTripThroughWriteAndReparse() + { + UsdStage stage = UsdaReader.Parse( + Wrap( + " double xformOp:rotateZ.timeSamples = {", + " 0: 0.0,", + " 24: 90.0,", + " 48: 180.0,", + " }"), + "TS"); + + AssertRoundTripSignature(stage); + } + + [Test] + public void DefaultPlusSamples_RoundTripThroughWriteAndReparse() + { + UsdStage stage = UsdaReader.Parse( + Wrap( + " double a = 5.0", + " double a.timeSamples = {", + " -6: -1.0,", + " 0.5: 0.0,", + " 24: 90.0,", + " }"), + "TS"); + + AssertRoundTripSignature(stage); + } + + [TestCase("float3 xformOp:translate", "(0, 0, 0)", "(1, 2, 3)")] + [TestCase("int[] a", "[1, 2, 3]", "[4, 5, 6]")] + [TestCase("asset a", "@./a.usda@", "@./b.usda@")] + [TestCase("token a", "\"open\"", "\"closed\"")] + public void TypedSamples_RoundTripThroughWriteAndReparse(string decl, string v0, string v24) + { + UsdStage stage = UsdaReader.Parse( + Wrap( + " " + decl + ".timeSamples = {", + " 0: " + v0 + ",", + " 24: " + v24 + ",", + " }"), + "TS"); + + AssertRoundTripSignature(stage); + } + + [Test] + public void WriterOutputIsAFixedPoint_ForTimeSamples() + { + UsdStage stage = UsdaReader.Parse( + Wrap( + " double a = 5.0", + " double a.timeSamples = {", + " 0: 0.0,", + " 24: 90.0,", + " }"), + "TS"); + + string firstWrite = UsdaWriter.Write(stage); + string secondWrite = UsdaWriter.Write(UsdaReader.Parse(firstWrite, stage.StageName)); + + Assert.That(secondWrite, Is.EqualTo(firstWrite)); + } + + [Test] + public void WriterEmitsTimeCodesWithoutRedundantDecimalPoint() + { + UsdStage stage = UsdaReader.Parse( + Wrap( + " double a.timeSamples = {", + " 0: 0.0,", + " 24: 90.0,", + " }"), + "TS"); + + string written = UsdaWriter.Write(stage); + + Assert.That(written, Does.Contain(".timeSamples = {")); + Assert.That(written, Does.Contain(" 0: 0.0,")); + Assert.That(written, Does.Contain(" 24: 90.0,")); + } + + // ---- signature ---------------------------------------------------------------- + + [Test] + public void Signature_DistinguishesDifferingSampleValues() + { + var a = new UsdStage("S"); + UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); + var attrA = new UsdAttribute("v", "double"); + attrA.TimeSamples[0.0] = 0.0; + attrA.TimeSamples[24.0] = 90.0; + pa.Attributes.Add(attrA); + + var b = new UsdStage("S"); + UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); + var attrB = new UsdAttribute("v", "double"); + attrB.TimeSamples[0.0] = 0.0; + attrB.TimeSamples[24.0] = 91.0; + pb.Attributes.Add(attrB); + + Assert.That(UsdSceneSignature.Compute(b), Is.Not.EqualTo(UsdSceneSignature.Compute(a))); + Assert.That(UsdSceneSignature.FirstDifference(a, b), Is.Not.Null); + } + + [Test] + public void Signature_DistinguishesPresenceOfSamples() + { + var withSamples = new UsdStage("S"); + UsdPrim ps = withSamples.AddRootPrim(new UsdPrim("X", "Xform")); + var sampled = new UsdAttribute("v", "double") { Value = 1.0 }; + sampled.TimeSamples[0.0] = 0.0; + ps.Attributes.Add(sampled); + + var withoutSamples = new UsdStage("S"); + UsdPrim pn = withoutSamples.AddRootPrim(new UsdPrim("X", "Xform")); + pn.Attributes.Add(new UsdAttribute("v", "double") { Value = 1.0 }); + + Assert.That( + UsdSceneSignature.Compute(withoutSamples), + Is.Not.EqualTo(UsdSceneSignature.Compute(withSamples))); + } + + [Test] + public void Signature_EqualForIdenticalSamples() + { + var a = new UsdStage("S"); + UsdPrim pa = a.AddRootPrim(new UsdPrim("X", "Xform")); + var attrA = new UsdAttribute("v", "double"); + attrA.TimeSamples[0.0] = 0.0; + attrA.TimeSamples[24.0] = 90.0; + pa.Attributes.Add(attrA); + + var b = new UsdStage("S"); + UsdPrim pb = b.AddRootPrim(new UsdPrim("X", "Xform")); + var attrB = new UsdAttribute("v", "double"); + attrB.TimeSamples[0.0] = 0.0; + attrB.TimeSamples[24.0] = 90.0; + pb.Attributes.Add(attrB); + + Assert.That(UsdSceneSignature.Compute(b), Is.EqualTo(UsdSceneSignature.Compute(a))); + } + + [Test] + public void Signature_OfSampleLessAttribute_IsUnchangedByTheFeature() + { + // A sample-less attribute's signature must be byte-for-byte what it was before time + // samples existed, so the existing round-trip corpus cannot be perturbed. + var stage = new UsdStage("S"); + UsdPrim prim = stage.AddRootPrim(new UsdPrim("X", "Xform")); + prim.Attributes.Add(new UsdAttribute("v", "int") { Value = 1L }); + + string signature = UsdSceneSignature.Compute(stage); + + Assert.That(signature, Does.Not.Contain("TS(")); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderCellTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderCellTests.cs new file mode 100644 index 0000000000..5be0b5c193 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderCellTests.cs @@ -0,0 +1,202 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + [TestFixture] + public class UsdaReaderCellTests + { + private const string R1 = "/Cell/Robots/R1"; + private const string R2 = "/Cell/Robots/R2"; + private const string R1Flange = "/Cell/Robots/R1/Base/J1/J2/J3/J4/J5/J6/Flange"; + + private UsdStage _stage = null!; + + [OneTimeSetUp] + public void LoadStage() + { + _stage = TestAssets.Load("Cell.usda"); + } + + [Test] + public void StageMetadata_IsParsed() + { + Assert.That(_stage.DefaultPrim, Is.EqualTo("Cell")); + Assert.That(_stage.UpAxis, Is.EqualTo("Z")); + Assert.That(_stage.MetersPerUnit, Is.EqualTo(1.0)); + Assert.That(_stage.RootLayerIdentifier, Is.EqualTo("Cell.usda")); + } + + [Test] + public void ComposedPrimCount_MatchesReference() + { + Assert.That(_stage.AllPrims().Count(), Is.EqualTo(53)); + } + + [Test] + public void CellRoot_HasCustomOverrideAttribute() + { + UsdPrim cell = UsdTestHelpers.RequirePrim(_stage, "/Cell"); + UsdAttribute over = UsdTestHelpers.RequireAttribute(cell, "inputs:speedOverride"); + Assert.That(over.Custom, Is.True); + Assert.That(over.TypeName, Is.EqualTo("double")); + Assert.That(over.Value, Is.EqualTo(100L)); + } + + [Test] + public void SafetyBeacon_TokenAndColorValues() + { + UsdPrim beacon = UsdTestHelpers.RequirePrim(_stage, "/Cell/SafetyBeacon"); + Assert.That(beacon.TypeName, Is.EqualTo("Sphere")); + + UsdAttribute visibility = UsdTestHelpers.RequireAttribute(beacon, "visibility"); + Assert.That(visibility.TypeName, Is.EqualTo("token")); + Assert.That(visibility.Value, Is.EqualTo("invisible")); + + UsdAttribute color = UsdTestHelpers.RequireAttribute(beacon, "primvars:displayColor"); + var outer = color.Value as List; + Assert.That(outer, Is.Not.Null); + Assert.That(outer![0] as object?[], Is.EqualTo(new object?[] { 1L, 0L, 0L })); + } + + [Test] + public void KeyLight_NumericAndTupleValues() + { + UsdPrim key = UsdTestHelpers.RequirePrim(_stage, "/Cell/KeyLight"); + Assert.That(key.TypeName, Is.EqualTo("DistantLight")); + + UsdAttribute intensity = UsdTestHelpers.RequireAttribute(key, "intensity"); + Assert.That(intensity.TypeName, Is.EqualTo("float")); + Assert.That(intensity.Value, Is.EqualTo(650L)); + + UsdAttribute rotate = UsdTestHelpers.RequireAttribute(key, "xformOp:rotateXYZ"); + Assert.That(rotate.TypeName, Is.EqualTo("double3")); + Assert.That(rotate.Value as object?[], Is.EqualTo(new object?[] { -45L, 0L, 35L })); + } + + [TestCase(R1)] + [TestCase(R2)] + public void RobotMount_IsComponentWithReferenceAndInstanceArcs(string mountPath) + { + UsdPrim mount = UsdTestHelpers.RequirePrim(_stage, mountPath); + Assert.That(mount.TypeName, Is.EqualTo("Xform")); + Assert.That(mount.Kind, Is.EqualTo(UsdPrimKindEnum.Component)); + + Assert.That(mount.Composition, Has.Count.EqualTo(2)); + Assert.That(mount.Composition[0].ArcKind, Is.EqualTo(UsdArcKindEnum.Reference)); + Assert.That(mount.Composition[0].AssetPath, Is.EqualTo("robot.usda")); + Assert.That(mount.Composition[0].PrimPath, Is.EqualTo("/Robot")); + Assert.That(mount.Composition[0].ListPosition, Is.EqualTo(UsdListOpTypeEnum.Append)); + Assert.That(mount.Composition[1].ArcKind, Is.EqualTo(UsdArcKindEnum.Instance)); + Assert.That(mount.Composition[1].AssetPath, Is.EqualTo("robot.usda")); + Assert.That(mount.Composition[1].PrimPath, Is.EqualTo("/Robot")); + } + + [TestCase(R1)] + [TestCase(R2)] + public void MergedRobotSubtree_IsPresentUnderMount(string mountPath) + { + Assert.That(_stage.Find(mountPath + "/Base"), Is.Not.Null); + Assert.That(_stage.Find(mountPath + "/Warning"), Is.Not.Null); + Assert.That(_stage.Find(mountPath + "/Materials/BaseGreen/Surface"), Is.Not.Null); + Assert.That(_stage.Find(mountPath + "/Base/J1/J2/J3/J4/J5/J6/Flange"), Is.Not.Null); + } + + [Test] + public void MergedBase_HasCollectionApiAndRemappedBinding() + { + UsdPrim basePrim = UsdTestHelpers.RequirePrim(_stage, R1 + "/Base"); + + List schemas = basePrim.ApiSchemas.Select(s => s.SchemaName).ToList(); + Assert.That(schemas, Is.EqualTo(new[] { "MaterialBindingAPI", "CollectionAPI" })); + + UsdApiSchema collection = basePrim.ApiSchemas.First(s => s.SchemaName == "CollectionAPI"); + Assert.That(collection.ExpansionRule, Is.EqualTo("expandPrims")); + + UsdRelationship binding = UsdTestHelpers.RequireRelationship(basePrim, "material:binding"); + Assert.That(binding.Targets, Is.EqualTo(new[] { R1 + "/Materials/BaseGreen" })); + } + + [Test] + public void MergedMaterial_ConnectionIsRemappedToMount() + { + UsdPrim green = UsdTestHelpers.RequirePrim(_stage, R1 + "/Materials/BaseGreen"); + UsdAttribute surface = UsdTestHelpers.RequireAttribute(green, "outputs:surface"); + Assert.That(surface.Connections, Is.EqualTo(new[] + { + R1 + "/Materials/BaseGreen/Surface.outputs:surface", + })); + } + + [Test] + public void MergedJoint_RotateAttributeIsLive() + { + UsdPrim j1 = UsdTestHelpers.RequirePrim(_stage, R1 + "/Base/J1"); + UsdAttribute rotateZ = UsdTestHelpers.RequireAttribute(j1, "xformOp:rotateZ"); + Assert.That(rotateZ.Live, Is.True); + + UsdPrim j2 = UsdTestHelpers.RequirePrim(_stage, R1 + "/Base/J1/J2"); + UsdAttribute rotateY = UsdTestHelpers.RequireAttribute(j2, "xformOp:rotateY"); + Assert.That(rotateY.Live, Is.True); + Assert.That(rotateY.Value, Is.EqualTo(-30L)); + } + + [Test] + public void Tool_IsReferencedOnR1FlangeOnly() + { + UsdPrim tool = UsdTestHelpers.RequirePrim(_stage, R1Flange + "/Tool"); + Assert.That(tool.TypeName, Is.EqualTo("Xform")); + Assert.That(tool.Kind, Is.EqualTo(UsdPrimKindEnum.Component)); + + Assert.That(tool.Composition, Has.Count.EqualTo(1)); + Assert.That(tool.Composition[0].ArcKind, Is.EqualTo(UsdArcKindEnum.Reference)); + Assert.That(tool.Composition[0].AssetPath, Is.EqualTo("tool.usda")); + Assert.That(tool.Composition[0].PrimPath, Is.EqualTo("/Gripper")); + + Assert.That(_stage.Find(R1Flange + "/Tool/Body"), Is.Not.Null); + Assert.That(_stage.Find(R1Flange + "/Tool/JawUpper"), Is.Not.Null); + Assert.That(_stage.Find(R1Flange + "/Tool/JawLower"), Is.Not.Null); + + // The tool is only attached to R1, never to R2. + Assert.That(_stage.Find(R2 + "/Base/J1/J2/J3/J4/J5/J6/Flange/Tool"), Is.Null); + } + + [Test] + public void Tool_IsNotInstanceable() + { + UsdPrim tool = UsdTestHelpers.RequirePrim(_stage, R1Flange + "/Tool"); + Assert.That(tool.Composition.Any(a => a.ArcKind == UsdArcKindEnum.Instance), Is.False); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderPlantTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderPlantTests.cs new file mode 100644 index 0000000000..4a4c2285e4 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdaReaderPlantTests.cs @@ -0,0 +1,219 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + [TestFixture] + public class UsdaReaderPlantTests + { + private UsdStage _stage = null!; + + [OneTimeSetUp] + public void LoadStage() + { + _stage = TestAssets.Load("Plant.usda"); + } + + [Test] + public void StageMetadata_IsParsed() + { + Assert.That(_stage.DefaultPrim, Is.EqualTo("Plant")); + Assert.That(_stage.UpAxis, Is.EqualTo("Z")); + Assert.That(_stage.MetersPerUnit, Is.EqualTo(1.0)); + Assert.That(_stage.RootLayerIdentifier, Is.EqualTo("Plant.usda")); + Assert.That(_stage.Documentation, Does.Contain("Base pump asset")); + } + + [Test] + public void PrimCountAndPaths_MatchReference() + { + List paths = _stage.AllPrims().Select(p => p.Path).OrderBy(p => p, System.StringComparer.Ordinal).ToList(); + Assert.That(paths, Has.Count.EqualTo(12)); + Assert.That(paths, Is.EqualTo(new[] + { + "/Plant", + "/Plant/Pumps", + "/Plant/Pumps/P101", + "/Plant/Pumps/P101/Bearing", + "/Plant/Pumps/P101/Body", + "/Plant/Pumps/P101/Impeller", + "/Plant/Pumps/P101/Impeller/BladeA", + "/Plant/Pumps/P101/Impeller/BladeB", + "/Plant/Pumps/P101/StatusLight", + "/Plant/Pumps/P101/StatusLight/Bulb", + "/Plant/Pumps/P101/StatusLight/Mat", + "/Plant/Pumps/P101/StatusLight/Mat/Surface", + })); + } + + [Test] + public void RootPrim_IsPlantXform() + { + Assert.That(_stage.RootPrims, Has.Count.EqualTo(1)); + UsdPrim plant = _stage.RootPrims[0]; + Assert.That(plant.Name, Is.EqualTo("Plant")); + Assert.That(plant.TypeName, Is.EqualTo("Xform")); + Assert.That(plant.Specifier, Is.EqualTo(UsdSpecifierEnum.Def)); + } + + [Test] + public void Pumps_IsScope() + { + UsdPrim pumps = UsdTestHelpers.RequirePrim(_stage, "/Plant/Pumps"); + Assert.That(pumps.TypeName, Is.EqualTo("Scope")); + Assert.That(pumps.Specifier, Is.EqualTo(UsdSpecifierEnum.Def)); + } + + [Test] + public void P101_HasKindAndReconstructedInstanceArc() + { + UsdPrim p101 = UsdTestHelpers.RequirePrim(_stage, "/Plant/Pumps/P101"); + Assert.That(p101.TypeName, Is.EqualTo("Xform")); + Assert.That(p101.Kind, Is.EqualTo(UsdPrimKindEnum.Component)); + + Assert.That(p101.Composition, Has.Count.EqualTo(2)); + + UsdCompositionArc reference = p101.Composition[0]; + Assert.That(reference.ArcKind, Is.EqualTo(UsdArcKindEnum.Reference)); + Assert.That(reference.AssetPath, Is.EqualTo("pump.usda")); + Assert.That(reference.PrimPath, Is.EqualTo("/Pump")); + Assert.That(reference.ListPosition, Is.EqualTo(UsdListOpTypeEnum.Append)); + + UsdCompositionArc instance = p101.Composition[1]; + Assert.That(instance.ArcKind, Is.EqualTo(UsdArcKindEnum.Instance)); + Assert.That(instance.AssetPath, Is.EqualTo("pump.usda")); + Assert.That(instance.PrimPath, Is.EqualTo("/Pump")); + Assert.That(instance.ListPosition, Is.EqualTo(UsdListOpTypeEnum.Append)); + } + + [Test] + public void Body_AttributeTypeNamesAndValues() + { + UsdPrim body = UsdTestHelpers.RequirePrim(_stage, "/Plant/Pumps/P101/Body"); + Assert.That(body.TypeName, Is.EqualTo("Cylinder")); + + UsdAttribute axis = UsdTestHelpers.RequireAttribute(body, "axis"); + Assert.That(axis.TypeName, Is.EqualTo("token")); + Assert.That(axis.Variability, Is.EqualTo(UsdVariabilityEnum.Uniform)); + Assert.That(axis.Value, Is.EqualTo("Z")); + + UsdAttribute radius = UsdTestHelpers.RequireAttribute(body, "radius"); + Assert.That(radius.TypeName, Is.EqualTo("double")); + Assert.That(radius.Value, Is.EqualTo(0.5)); + + UsdAttribute color = UsdTestHelpers.RequireAttribute(body, "primvars:displayColor"); + Assert.That(color.TypeName, Is.EqualTo("color3f[]")); + var outer = color.Value as List; + Assert.That(outer, Is.Not.Null); + Assert.That(outer!, Has.Count.EqualTo(1)); + var tuple = outer[0] as object?[]; + Assert.That(tuple, Is.Not.Null); + Assert.That(tuple!, Is.EqualTo(new object?[] { 0L, 0L, 1L })); + + UsdAttribute order = UsdTestHelpers.RequireAttribute(body, "xformOpOrder"); + Assert.That(order.TypeName, Is.EqualTo("token[]")); + Assert.That(order.Variability, Is.EqualTo(UsdVariabilityEnum.Uniform)); + Assert.That(order.Value, Is.EqualTo(new List { "xformOp:translate" })); + } + + [Test] + public void Impeller_LiveAndCustomAttributes() + { + UsdPrim impeller = UsdTestHelpers.RequirePrim(_stage, "/Plant/Pumps/P101/Impeller"); + + UsdAttribute rotateZ = UsdTestHelpers.RequireAttribute(impeller, "xformOp:rotateZ"); + Assert.That(rotateZ.TypeName, Is.EqualTo("double")); + Assert.That(rotateZ.Variability, Is.EqualTo(UsdVariabilityEnum.Varying)); + Assert.That(rotateZ.Live, Is.True); + Assert.That(rotateZ.Value, Is.TypeOf()); + Assert.That(rotateZ.Value, Is.Zero); + + UsdAttribute setpoint = UsdTestHelpers.RequireAttribute(impeller, "inputs:speedSetpoint"); + Assert.That(setpoint.Custom, Is.True); + Assert.That(setpoint.TypeName, Is.EqualTo("double")); + + UsdAttribute order = UsdTestHelpers.RequireAttribute(impeller, "xformOpOrder"); + Assert.That(order.Value, Is.EqualTo(new List { "xformOp:translate", "xformOp:rotateZ" })); + } + + [Test] + public void Bulb_HasAppliedApiSchemaAndRelationship() + { + UsdPrim bulb = UsdTestHelpers.RequirePrim(_stage, "/Plant/Pumps/P101/StatusLight/Bulb"); + Assert.That(bulb.TypeName, Is.EqualTo("Sphere")); + Assert.That(bulb.ApiSchemas.Select(s => s.SchemaName), Is.EqualTo(new[] { "MaterialBindingAPI" })); + + UsdRelationship binding = UsdTestHelpers.RequireRelationship(bulb, "material:binding"); + Assert.That(binding.Targets, Is.EqualTo(new[] { "/Plant/Pumps/P101/StatusLight/Mat" })); + } + + [Test] + public void Material_ConnectionIsParsed() + { + UsdPrim mat = UsdTestHelpers.RequirePrim(_stage, "/Plant/Pumps/P101/StatusLight/Mat"); + Assert.That(mat.TypeName, Is.EqualTo("Material")); + + UsdAttribute surface = UsdTestHelpers.RequireAttribute(mat, "outputs:surface"); + Assert.That(surface.TypeName, Is.EqualTo("token")); + Assert.That(surface.Value, Is.Null); + Assert.That(surface.Connections, Is.EqualTo(new[] + { + "/Plant/Pumps/P101/StatusLight/Mat/Surface.outputs:surface", + })); + } + + [Test] + public void Shader_ScalarTupleAndUnvaluedAttributes() + { + UsdPrim shader = UsdTestHelpers.RequirePrim(_stage, "/Plant/Pumps/P101/StatusLight/Mat/Surface"); + Assert.That(shader.TypeName, Is.EqualTo("Shader")); + + UsdAttribute id = UsdTestHelpers.RequireAttribute(shader, "info:id"); + Assert.That(id.TypeName, Is.EqualTo("token")); + Assert.That(id.Variability, Is.EqualTo(UsdVariabilityEnum.Uniform)); + Assert.That(id.Value, Is.EqualTo("UsdPreviewSurface")); + + UsdAttribute diffuse = UsdTestHelpers.RequireAttribute(shader, "inputs:diffuseColor"); + Assert.That(diffuse.TypeName, Is.EqualTo("color3f")); + Assert.That(diffuse.Value as object?[], Is.EqualTo(new object?[] { 0.1, 0.1, 0.1 })); + + UsdAttribute emissive = UsdTestHelpers.RequireAttribute(shader, "inputs:emissiveColor"); + Assert.That(emissive.Value as object?[], Is.EqualTo(new object?[] { 0L, 0L, 0L })); + + UsdAttribute outputs = UsdTestHelpers.RequireAttribute(shader, "outputs:surface"); + Assert.That(outputs.Value, Is.Null); + Assert.That(outputs.Connections, Is.Empty); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdaRoundTripTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdaRoundTripTests.cs new file mode 100644 index 0000000000..96810dcc8a --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdaRoundTripTests.cs @@ -0,0 +1,131 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.IO; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Reproduces roundtrip_check.py: parse a composed layer, write it back to a single + /// flattened .usda layer, re-parse it, and assert the ยง7.4 scene signatures are equal. + /// + [TestFixture] + public class UsdaRoundTripTests + { + [TestCase("Plant.usda")] + [TestCase("Cell.usda")] + public void SignatureIsPreservedThroughWriteAndReparse(string asset) + { + UsdStage expected = UsdaReader.ParseFile(TestAssets.PathTo(asset)); + + string written = UsdaWriter.Write(expected); + UsdStage reparsed = UsdaReader.Parse(written, expected.StageName); + + string? difference = UsdSceneSignature.FirstDifference(expected, reparsed); + Assert.That( + UsdSceneSignature.Compute(reparsed), + Is.EqualTo(UsdSceneSignature.Compute(expected)), + difference ?? "signatures are unexpectedly equal"); + } + + [TestCase("Plant.usda")] + [TestCase("Cell.usda")] + public void PrimCountIsPreservedThroughWriteAndReparse(string asset) + { + UsdStage expected = UsdaReader.ParseFile(TestAssets.PathTo(asset)); + UsdStage reparsed = UsdaReader.Parse(UsdaWriter.Write(expected), expected.StageName); + + Assert.That(reparsed.AllPrims().Count(), Is.EqualTo(expected.AllPrims().Count())); + } + + [TestCase("Plant.usda")] + [TestCase("Cell.usda")] + public void WriterOutputIsAFixedPoint(string asset) + { + UsdStage expected = UsdaReader.ParseFile(TestAssets.PathTo(asset)); + + string firstWrite = UsdaWriter.Write(expected); + string secondWrite = UsdaWriter.Write(UsdaReader.Parse(firstWrite, expected.StageName)); + + Assert.That(secondWrite, Is.EqualTo(firstWrite)); + } + + [Test] + public void WriteToFile_ProducesSameTextAsWrite() + { + UsdStage stage = TestAssets.Load("Plant.usda"); + string expectedText = UsdaWriter.Write(stage); + + string target = Path.Combine(TestContext.CurrentContext.WorkDirectory, "roundtrip_plant.usda"); + try + { + UsdaWriter.WriteToFile(stage, target); + string onDisk = File.ReadAllText(target); + Assert.That(onDisk, Is.EqualTo(expectedText)); + } + finally + { + if (File.Exists(target)) + { + File.Delete(target); + } + } + } + + [Test] + public void ReparsedFileWithoutOverlays_MatchesComposedSignature() + { + UsdStage expected = UsdaReader.ParseFile(TestAssets.PathTo("Cell.usda")); + + string target = Path.Combine(TestContext.CurrentContext.WorkDirectory, "roundtrip_cell.usda"); + try + { + UsdaWriter.WriteToFile(expected, target); + UsdStage reparsed = UsdaReader.ParseFile(target, expected.StageName, applyExampleOverlays: false); + + string? difference = UsdSceneSignature.FirstDifference(expected, reparsed); + Assert.That( + UsdSceneSignature.Compute(reparsed), + Is.EqualTo(UsdSceneSignature.Compute(expected)), + difference ?? "signatures are unexpectedly equal"); + } + finally + { + if (File.Exists(target)) + { + File.Delete(target); + } + } + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdaValueParsingTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdaValueParsingTests.cs new file mode 100644 index 0000000000..b9efee0b99 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdaValueParsingTests.cs @@ -0,0 +1,181 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + [TestFixture] + public class UsdaValueParsingTests + { + [TestCase("42", 42L)] + [TestCase("-5", -5L)] + [TestCase("+7", 7L)] + [TestCase("0", 0L)] + public void Integers_ParseAsLong(string raw, long expected) + { + Assert.That(UsdaReader.ParseValue(raw), Is.EqualTo(expected)); + } + + [TestCase("3.14", 3.14)] + [TestCase("-2.5", -2.5)] + [TestCase("1.0", 1.0)] + [TestCase(".25", 0.25)] + [TestCase("-2.5e-3", -0.0025)] + [TestCase("6.02E2", 602.0)] + public void Floats_ParseAsDouble(string raw, double expected) + { + object? value = UsdaReader.ParseValue(raw); + Assert.That(value, Is.TypeOf()); + Assert.That((double)value!, Is.EqualTo(expected).Within(1e-12)); + } + + [TestCase("true", true)] + [TestCase("false", false)] + public void Booleans_ParseAsBool(string raw, bool expected) + { + Assert.That(UsdaReader.ParseValue(raw), Is.EqualTo(expected)); + } + + [Test] + public void QuotedString_IsUnwrapped() + { + Assert.That(UsdaReader.ParseValue("\"hello world\""), Is.EqualTo("hello world")); + } + + [Test] + public void QuotedString_WithSpecialCharacters() + { + Assert.That(UsdaReader.ParseValue("\"a, (b) [c] \""), Is.EqualTo("a, (b) [c] ")); + } + + [Test] + public void QuotedString_WithEscapedQuotes() + { + Assert.That(UsdaReader.ParseValue("\"say \\\"hi\\\"\""), Is.EqualTo("say \"hi\"")); + } + + [Test] + public void BareToken_IsReturnedAsString() + { + Assert.That(UsdaReader.ParseValue("inherited"), Is.EqualTo("inherited")); + } + + [TestCase("@pump.usda@", "pump.usda")] + [TestCase("@./sub/robot.usda@", "./sub/robot.usda")] + public void AssetPath_IsUnwrapped(string raw, string expected) + { + Assert.That(UsdaReader.ParseValue(raw), Is.EqualTo(expected)); + } + + [Test] + public void PathReference_IsUnwrapped() + { + Assert.That(UsdaReader.ParseValue(""), Is.EqualTo("/Plant/Pumps/P101")); + } + + [Test] + public void IntegerTuple_ParsesToObjectArray() + { + Assert.That(UsdaReader.ParseValue("(0, 0, 0)"), Is.EqualTo(new object?[] { 0L, 0L, 0L })); + Assert.That(UsdaReader.ParseValue("(-45, 0, 35)"), Is.EqualTo(new object?[] { -45L, 0L, 35L })); + } + + [Test] + public void FloatTuple_ParsesToObjectArray() + { + Assert.That(UsdaReader.ParseValue("(0.1, 0.1, 0.1)"), Is.EqualTo(new object?[] { 0.1, 0.1, 0.1 })); + } + + [Test] + public void IntegerArray_ParsesToList() + { + Assert.That(UsdaReader.ParseValue("[1, 2, 3]"), Is.EqualTo(new List { 1L, 2L, 3L })); + } + + [Test] + public void NestedTupleArray_ParsesToListOfArray() + { + object? value = UsdaReader.ParseValue("[(0, 0, 1)]"); + var list = value as List; + Assert.That(list, Is.Not.Null); + Assert.That(list!, Has.Count.EqualTo(1)); + Assert.That(list[0] as object?[], Is.EqualTo(new object?[] { 0L, 0L, 1L })); + } + + [Test] + public void StringArray_ParsesToList() + { + Assert.That( + UsdaReader.ParseValue("[\"xformOp:translate\", \"xformOp:rotateZ\"]"), + Is.EqualTo(new List { "xformOp:translate", "xformOp:rotateZ" })); + } + + [Test] + public void MixedFloatTuple_HandlesLeadingDotAndExponent() + { + object? value = UsdaReader.ParseValue("(0.5, -1.5e2, .25)"); + var tuple = value as object?[]; + Assert.That(tuple, Is.Not.Null); + Assert.That(tuple!, Has.Length.EqualTo(3)); + Assert.That((double)tuple[0]!, Is.EqualTo(0.5).Within(1e-12)); + Assert.That((double)tuple[1]!, Is.EqualTo(-150.0).Within(1e-12)); + Assert.That((double)tuple[2]!, Is.EqualTo(0.25).Within(1e-12)); + } + + [TestCase("")] + [TestCase(" ")] + public void EmptyOrWhitespace_ParsesToNull(string raw) + { + Assert.That(UsdaReader.ParseValue(raw), Is.Null); + } + + [Test] + public void Null_ParsesToNull() + { + Assert.That(UsdaReader.ParseValue(null), Is.Null); + } + + [Test] + public void StripComments_RemovesHashCommentsButKeepsQuotedHash() + { + string input = string.Join("\n", + "def Xform \"A\" # trailing comment", + "{", + " token label = \"value # not a comment\"", + "}"); + string stripped = UsdaReader.StripComments(input); + + Assert.That(stripped, Does.Not.Contain("trailing comment")); + Assert.That(stripped, Does.Contain("value # not a comment")); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/UsdaWriterInjectionTests.cs b/tests/Opc.Ua.OpenUsd.Tests/UsdaWriterInjectionTests.cs new file mode 100644 index 0000000000..4cbf614018 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/UsdaWriterInjectionTests.cs @@ -0,0 +1,179 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// A materialized attribute is remotely writable and the exporter reads it + /// back verbatim, so a value must not be able to terminate the literal it is + /// authored into and author its own layer syntax. + /// + /// + /// The property asserted is that no payload ever becomes a line of its own + /// in the emitted layer: a standards-compliant USD consumer resolves + /// composition arcs and prim declarations from layer syntax, so a value that + /// stays inside its literal cannot introduce either. The payload text itself + /// may still appear, inertly, within the literal. + /// + [TestFixture] + public class UsdaWriterInjectionTests + { + /// + /// Closes the quote, then tries to author a reference arc a renderer + /// opening the exported file would resolve. + /// + private const string ArcPayload = + "x\" )\n prepend references = @/etc/passwd@\n)\n{\n #"; + + [Test] + public void AStringValueCannotAuthorAnArcLine() + { + UsdStage stage = CreateStage("string", ArcPayload); + + string written = UsdaWriter.Write(stage); + + AssertNoInjectedLine(written); + AssertNoInjection(UsdaReader.Parse(written, stage.StageName)); + } + + [Test] + public void AnAssetValueCannotEscapeItsDelimiters() + { + UsdStage stage = CreateStage("asset", "a@c\nd"); + + string written = UsdaWriter.Write(stage); + + // '@', '<' and '>' would close the asset reference and a newline + // would end the line, so none may survive into the literal. + string assetLine = written + .Split('\n') + .Single(line => line.Contains("asset label", StringComparison.Ordinal)); + Assert.That(assetLine, Does.Contain("@abcd@")); + AssertNoInjectedLine(written); + } + + [Test] + public void DocumentationCannotAuthorAPrimLine() + { + UsdStage stage = CreateStage("string", "safe"); + stage.RootPrims[0].Documentation = + "d\"\"\"\n)\nover \"Injected\"\n{\n #"; + + string written = UsdaWriter.Write(stage); + + AssertNoInjectedLine(written); + Assert.That( + UsdaReader.Parse(written, stage.StageName) + .AllPrims().Any(p => p.Name == "Injected"), + Is.False, + "documentation must not be able to author a sibling prim"); + } + + [Test] + public void AVariantSelectionCannotAuthorAnArcLine() + { + UsdStage stage = CreateStage("string", "safe"); + stage.RootPrims[0].VariantSets.Add( + new UsdVariantSet("look") + { + Selection = "a\" }\n prepend references = @/etc/passwd@\n" + + " variants = { string x = \"" + }); + + string written = UsdaWriter.Write(stage); + + AssertNoInjectedLine(written); + } + + /// + /// Asserts no emitted line is itself a composition arc or a prim + /// declaration contributed by a payload. The single prim the test + /// authors, Target, is the only legitimate declaration. + /// + private static void AssertNoInjectedLine(string written) + { + string[] offenders = written + .Split('\n') + .Select(line => line.Trim()) + .Where(IsInjectedLine) + .ToArray(); + + Assert.That( + offenders, + Is.Empty, + "a payload escaped its literal and became layer syntax"); + } + + private static bool IsInjectedLine(string line) + { + if (line.StartsWith("prepend references", StringComparison.Ordinal) || + line.StartsWith("references =", StringComparison.Ordinal) || + line.StartsWith("payload =", StringComparison.Ordinal)) + { + return true; + } + + bool declaresPrim = + line.StartsWith("over ", StringComparison.Ordinal) || + line.StartsWith("def ", StringComparison.Ordinal) || + line.StartsWith("class ", StringComparison.Ordinal); + return declaresPrim && !line.Contains("\"Target\"", StringComparison.Ordinal); + } + + private static void AssertNoInjection(UsdStage reparsed) + { + Assert.Multiple(() => + { + Assert.That( + reparsed.RootPrims, + Has.Count.EqualTo(1), + "no extra prim may be authored"); + Assert.That( + reparsed.AllPrims().SelectMany(p => p.Composition), + Is.Empty, + "no composition arc may be authored"); + }); + } + + private static UsdStage CreateStage(string typeName, string value) + { + var stage = new UsdStage("Injection"); + var prim = new UsdPrim("Target"); + prim.Attributes.Add(new UsdAttribute("label", typeName) { Value = value }); + stage.AddRootPrim(prim); + return stage; + } + } +} \ No newline at end of file diff --git a/tests/Opc.Ua.OpenUsd.Tests/VariantBranchConversionTests.cs b/tests/Opc.Ua.OpenUsd.Tests/VariantBranchConversionTests.cs new file mode 100644 index 0000000000..704728be40 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/VariantBranchConversionTests.cs @@ -0,0 +1,303 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Conversion; +using Opc.Ua.OpenUsdScene.Scene; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Conversion-layer tests for full <Variant> branch fidelity (ยง5.6, Composition + /// Provenance CU): the reader captures every authored branch of a + /// variantSet "name" = { โ€ฆ } block โ€” not only the selected one โ€” into + /// , the writer re-emits the block preserving branch order, + /// and the ยง7.4 signature deliberately ignores non-selected branches (they are un-composed + /// authoring provenance) while still distinguishing the resolved selection. + /// + [TestFixture] + public class VariantBranchConversionTests + { + private static string Wrap(params string[] bodyLines) + { + var lines = new List + { + "#usda 1.0", + "(", + " defaultPrim = \"P\"", + " metersPerUnit = 1.0", + " upAxis = \"Z\"", + ")", + string.Empty, + "def Xform \"P\"", + "{", + }; + lines.AddRange(bodyLines); + lines.Add("}"); + lines.Add(string.Empty); + return string.Join("\n", lines); + } + + private static UsdVariantSet ParseSingleVariantSet(params string[] bodyLines) + { + UsdStage stage = UsdaReader.Parse(Wrap(bodyLines), "V"); + UsdPrim prim = stage.Find("/P")!; + return prim.VariantSets.Single(); + } + + // ---- parsing ------------------------------------------------------------------ + + [Test] + public void MultipleBranches_WithNoSelection_AreAllParsed() + { + UsdVariantSet set = ParseSingleVariantSet( + " variantSet \"lod\" = {", + " \"high\" {", + " int resolution = 1024", + " }", + " \"low\" {", + " int resolution = 256", + " }", + " }"); + + // No 'variants = {...}' metadata was authored, so nothing is selected. + Assert.That(set.SetName, Is.EqualTo("lod")); + Assert.That(set.Selection, Is.EqualTo(string.Empty)); + // Both branches captured, in authored order. + Assert.That(set.Variants.Select(v => v.Name), Is.EqualTo(new[] { "high", "low" })); + } + + [Test] + public void BranchBody_CapturesAttributes() + { + UsdVariantSet set = ParseSingleVariantSet( + " variantSet \"lod\" = {", + " \"high\" {", + " int resolution = 1024", + " token quality = \"best\"", + " }", + " }"); + + UsdPrim high = set.Variants.Single(); + Assert.That(high.Attributes.Select(a => a.Name), Is.EqualTo(new[] { "resolution", "quality" })); + Assert.That(high.Attributes[0].Value, Is.EqualTo(1024L)); + Assert.That(high.Attributes[1].Value, Is.EqualTo("best")); + } + + [Test] + public void BranchBody_CapturesChildPrims() + { + UsdVariantSet set = ParseSingleVariantSet( + " variantSet \"lod\" = {", + " \"high\" {", + " def Xform \"Detail\"", + " {", + " int subdivisions = 3", + " }", + " }", + " }"); + + UsdPrim high = set.Variants.Single(); + Assert.That(high.Children.Select(c => c.Name), Is.EqualTo(new[] { "Detail" })); + Assert.That(high.Children[0].TypeName, Is.EqualTo("Xform")); + Assert.That(high.Children[0].Attributes.Single().Name, Is.EqualTo("subdivisions")); + } + + [Test] + public void SingleLineVariantSetBlock_IsParsed() + { + UsdVariantSet set = ParseSingleVariantSet( + " variantSet \"lod\" = { \"high\" { } \"low\" { } }"); + + Assert.That(set.Variants.Select(v => v.Name), Is.EqualTo(new[] { "high", "low" })); + } + + [Test] + public void SelectionAndBranches_Coexist() + { + // The selection lives in the prim metadata header; the branches live in the body. + var usda = string.Join( + "\n", + "#usda 1.0", + "(", + " defaultPrim = \"P\"", + " metersPerUnit = 1.0", + " upAxis = \"Z\"", + ")", + string.Empty, + "def Xform \"P\" (", + " variants = {", + " string lod = \"high\"", + " }", + ")", + "{", + " variantSet \"lod\" = {", + " \"high\" {", + " int resolution = 1024", + " }", + " \"low\" {", + " int resolution = 256", + " }", + " }", + "}", + string.Empty); + + UsdPrim prim = UsdaReader.Parse(usda, "V").Find("/P")!; + UsdVariantSet set = prim.VariantSets.Single(); + Assert.That(set.Selection, Is.EqualTo("high")); + Assert.That(set.Variants.Select(v => v.Name), Is.EqualTo(new[] { "high", "low" })); + } + + // ---- writing / round-trip ----------------------------------------------------- + + [Test] + public void Writer_ReEmitsBranches_PreservingOrder() + { + UsdStage stage = BuildStageWithBranches("first", "second", "third"); + string written = UsdaWriter.Write(stage); + + Assert.That(written, Does.Contain("variantSet \"lod\" = {")); + int firstIdx = written.IndexOf("\"first\" {", System.StringComparison.Ordinal); + int secondIdx = written.IndexOf("\"second\" {", System.StringComparison.Ordinal); + int thirdIdx = written.IndexOf("\"third\" {", System.StringComparison.Ordinal); + Assert.That(firstIdx, Is.GreaterThan(0)); + Assert.That(secondIdx, Is.GreaterThan(firstIdx)); + Assert.That(thirdIdx, Is.GreaterThan(secondIdx)); + } + + [Test] + public void Branches_RoundTripThroughWriteAndReparse() + { + UsdStage stage = BuildStageWithBranches("high", "low"); + // Give the branches distinct bodies so a lost or reordered branch is observable. + UsdVariantSet set = stage.Find("/P")!.VariantSets.Single(); + set.Variants[0].Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); + set.Variants[1].Attributes.Add(new UsdAttribute("resolution", "int") { Value = 256L }); + + string written = UsdaWriter.Write(stage); + UsdStage reparsed = UsdaReader.Parse(written, stage.StageName); + + UsdVariantSet reSet = reparsed.Find("/P")!.VariantSets.Single(); + Assert.That(reSet.Variants.Select(v => v.Name), Is.EqualTo(new[] { "high", "low" })); + Assert.That(reSet.Variants[0].Attributes.Single().Value, Is.EqualTo(1024L)); + Assert.That(reSet.Variants[1].Attributes.Single().Value, Is.EqualTo(256L)); + } + + [Test] + public void WriterOutputIsAFixedPoint_ForVariantBranches() + { + UsdStage stage = BuildStageWithBranches("high", "low"); + UsdVariantSet set = stage.Find("/P")!.VariantSets.Single(); + set.Variants[0].Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); + set.Variants[0].AddChild(new UsdPrim("Detail", "Xform")); + + string firstWrite = UsdaWriter.Write(stage); + string secondWrite = UsdaWriter.Write(UsdaReader.Parse(firstWrite, stage.StageName)); + + Assert.That(secondWrite, Is.EqualTo(firstWrite)); + } + + // ---- signature ---------------------------------------------------------------- + + [Test] + public void Signature_IgnoresNonSelectedBranches() + { + // Two stages identical in name/selection but with different branch *bodies* must sign + // identically: the branches are un-composed authoring provenance, out of ยง7.4 scope. + UsdStage a = BuildStageWithBranches("high", "low"); + a.Find("/P")!.VariantSets.Single().Selection = "high"; + a.Find("/P")!.VariantSets.Single().Variants[0] + .Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); + + UsdStage b = BuildStageWithBranches("high", "low"); + b.Find("/P")!.VariantSets.Single().Selection = "high"; + b.Find("/P")!.VariantSets.Single().Variants[0] + .Attributes.Add(new UsdAttribute("resolution", "int") { Value = 256L }); + b.Find("/P")!.VariantSets.Single().Variants[1] + .Attributes.Add(new UsdAttribute("extra", "double") { Value = 3.5 }); + + Assert.That( + UsdSceneSignature.Compute(b), + Is.EqualTo(UsdSceneSignature.Compute(a)), + "non-selected branch bodies must not affect the composed-scene signature"); + } + + [Test] + public void Signature_IgnoresBranchPresenceEntirely() + { + // A stage with captured branches and one with none (same set name/selection) sign the + // same, so adding branch capture never perturbs an existing signature (ยง7.4). + UsdStage withBranches = BuildStageWithBranches("high", "low"); + withBranches.Find("/P")!.VariantSets.Single().Selection = "high"; + + var withoutBranches = new UsdStage("V") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.VariantSets.Add(new UsdVariantSet("lod", "high")); + withoutBranches.AddRootPrim(prim); + + Assert.That( + UsdSceneSignature.Compute(withBranches), + Is.EqualTo(UsdSceneSignature.Compute(withoutBranches))); + } + + [Test] + public void Signature_DistinguishesSelection() + { + UsdStage a = BuildStageWithBranches("high", "low"); + a.Find("/P")!.VariantSets.Single().Selection = "high"; + + UsdStage b = BuildStageWithBranches("high", "low"); + b.Find("/P")!.VariantSets.Single().Selection = "low"; + + Assert.That( + UsdSceneSignature.Compute(b), + Is.Not.EqualTo(UsdSceneSignature.Compute(a)), + "the resolved selection is a composed-scene property and must be signed"); + Assert.That(UsdSceneSignature.FirstDifference(a, b), Is.Not.Null); + } + + // ---- helpers ------------------------------------------------------------------- + + private static UsdStage BuildStageWithBranches(params string[] branchNames) + { + var stage = new UsdStage("V") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + var set = new UsdVariantSet("lod"); + foreach (string name in branchNames) + { + set.Variants.Add(new UsdPrim(name)); + } + prim.VariantSets.Add(set); + stage.AddRootPrim(prim); + return stage; + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/VariantBranchTests.cs b/tests/Opc.Ua.OpenUsd.Tests/VariantBranchTests.cs new file mode 100644 index 0000000000..a6477de84f --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/VariantBranchTests.cs @@ -0,0 +1,255 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.OpenUsdScene.Scene; +using Opc.Ua.OpenUsdScene.Server; + +namespace Opc.Ua.OpenUsdScene.Tests +{ + /// + /// Composition provenance โ€” variant branches in the address space (ยง5.6): a + /// materialized UsdVariantSetType exposes every authored <Variant> branch + /// as a UsdPrimType Object under the set, in addition to SetName/Selection, + /// so a client can browse into any branch โ€” not only the selected one โ€” and recover its full + /// prim-shaped body. Branches come from ; a set with no + /// captured branches materializes none (fail closed โ€” nothing is invented from the selection + /// name). + /// + /// + /// This fixture supersedes the earlier stopgap that synthesized a single branch object from the + /// resolved Selection string. Full-fidelity branch capture changed that behaviour: the + /// materializer now authors exactly the branches present in + /// with their bodies, so these tests were updated to the corrected contract. + /// + [TestFixture] + public class VariantBranchTests + { + // ---- Every authored branch is materialized ------------------------------------- + + [Test] + public void VariantSet_MaterializesEveryAuthoredBranchObject() + { + var set = new UsdVariantSet("lod", "high"); + set.Variants.Add(new UsdPrim("high")); + set.Variants.Add(new UsdPrim("low")); + (MaterializedScene ms, UsdVariantSetState node) = MaterializeSet(set); + + // SetName/Selection are authored exactly as before branches were captured. + Assert.That(node.SetName!.Value, Is.EqualTo("lod")); + Assert.That(node.Selection!.Value, Is.EqualTo("high")); + + List branches = + MaterializationHarness.ChildrenOfType(ms.Context, node); + // Both branches are present, in authored order, not just the selected one. + Assert.That(branches.Select(b => b.BrowseName.Name), Is.EqualTo(new[] { "high", "low" })); + + UsdPrimState high = branches[0]; + Assert.That(high.BrowseName.NamespaceIndex, Is.EqualTo(ms.Namespace)); + Assert.That(high.NodeId.IsNull, Is.False); + // A branch browses like any other prim โ€” it is a UsdPrimType Object (ยง5.6). + Assert.That( + high.TypeDefinitionId, + Is.EqualTo(new NodeId(Opc.Ua.OpenUsdScene.ObjectTypes.UsdPrimType, ms.Namespace))); + } + + [Test] + public void UnselectedBranch_IsStillMaterialized() + { + // Nothing is selected, yet both authored branches must still be materialized: ยง5.6 + // covers the full branch structure, independent of the resolved selection. + var set = new UsdVariantSet("shading", string.Empty); + set.Variants.Add(new UsdPrim("pbr")); + set.Variants.Add(new UsdPrim("unlit")); + (MaterializedScene ms, UsdVariantSetState node) = MaterializeSet(set); + + Assert.That(node.Selection!.Value, Is.EqualTo(string.Empty)); + List branches = + MaterializationHarness.ChildrenOfType(ms.Context, node); + Assert.That(branches.Select(b => b.BrowseName.Name), Is.EqualTo(new[] { "pbr", "unlit" })); + } + + // ---- Fail closed when no branch was captured ----------------------------------- + + [Test] + public void SelectionWithoutCapturedBranches_MaterializesNoBranch() + { + // A set that recorded only a resolved selection (no captured branches) invents no + // branch object from the selection name โ€” the selection stays on the Selection + // property alone (fail closed). + var set = new UsdVariantSet("lod", "high"); + (MaterializedScene ms, UsdVariantSetState node) = MaterializeSet(set); + + Assert.That( + MaterializationHarness.ChildrenOfType(ms.Context, node), Is.Empty); + Assert.That(node.SetName!.Value, Is.EqualTo("lod")); + Assert.That(node.Selection!.Value, Is.EqualTo("high")); + } + + // ---- Branch bodies are materialized with full prim fidelity -------------------- + + [Test] + public void BranchBody_MaterializesAttributes() + { + var high = new UsdPrim("high"); + high.Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); + var set = new UsdVariantSet("lod", "high"); + set.Variants.Add(high); + (MaterializedScene ms, UsdVariantSetState node) = MaterializeSet(set); + + UsdPrimState branch = + MaterializationHarness.ChildrenOfType(ms.Context, node).Single(); + + UsdAttributeState attr = + MaterializationHarness.ChildrenOfType(ms.Context, branch).Single(); + Assert.That(attr.BrowseName.Name, Is.EqualTo("resolution")); + Assert.That(attr.UsdTypeName!.Value, Is.EqualTo("int")); + } + + [Test] + public void BranchBody_MaterializesChildPrims() + { + var high = new UsdPrim("high"); + high.AddChild(new UsdPrim("Detail", "Xform")); + var set = new UsdVariantSet("lod", "high"); + set.Variants.Add(high); + (MaterializedScene ms, UsdVariantSetState node) = MaterializeSet(set); + + UsdPrimState branch = + MaterializationHarness.ChildrenOfType(ms.Context, node).Single(); + UsdPrimState childPrim = + MaterializationHarness.ChildrenOfType(ms.Context, branch).Single(); + Assert.That(childPrim.BrowseName.Name, Is.EqualTo("Detail")); + } + + // ---- Branches on different sets get distinct identities ------------------------ + + [Test] + public void BranchesOnDifferentSets_ProduceDistinctBranchNodeIds() + { + var stage = new UsdStage("V") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + var lod = new UsdVariantSet("lod", "high"); + lod.Variants.Add(new UsdPrim("high")); + var shading = new UsdVariantSet("shading", "pbr"); + shading.Variants.Add(new UsdPrim("pbr")); + prim.VariantSets.Add(lod); + prim.VariantSets.Add(shading); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + + FolderState variantSets = ms.Prim("/P").VariantSets!; + List sets = + MaterializationHarness.ChildrenOfType(ms.Context, variantSets); + Assert.That(sets, Has.Count.EqualTo(2)); + + var branchIds = new List(); + foreach (UsdVariantSetState set in sets) + { + UsdPrimState branch = + MaterializationHarness.ChildrenOfType(ms.Context, set).Single(); + Assert.That(branch.NodeId.IsNull, Is.False); + branchIds.Add(branch.NodeId); + } + + // Regression guard: AddxVariant_ leaves the type's placeholder NodeId, so without the + // explicit per-instance NodeId assignment both branches would collide on one id. + Assert.That(branchIds[0], Is.Not.EqualTo(branchIds[1])); + } + + // ---- The branch does not leak into the prim tree ------------------------------- + + [Test] + public void VariantBranch_IsNotADirectPrimChild() + { + var set = new UsdVariantSet("lod", "high"); + set.Variants.Add(new UsdPrim("high")); + (MaterializedScene ms, _) = MaterializeSet(set); + + // The branch hangs under VariantSets/, never directly under the prim, so a + // prim-tree walk (as the exporter does) never double-counts it as a child prim. + List directChildPrims = + MaterializationHarness.ChildrenOfType(ms.Context, ms.Prim("/P")); + Assert.That(directChildPrims, Is.Empty); + } + + // ---- Round trip: branches survive materialize -> export, add no phantom child -- + + [Test] + public void Branches_RoundTripThroughMaterializeAndExport() + { + var high = new UsdPrim("high"); + high.Attributes.Add(new UsdAttribute("resolution", "int") { Value = 1024L }); + var set = new UsdVariantSet("lod", "high"); + set.Variants.Add(high); + set.Variants.Add(new UsdPrim("low")); + + var stage = new UsdStage("V") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.VariantSets.Add(set); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + UsdStage exported = ms.Context.ExportUsdStage(ms.Result); + + UsdPrim exportedPrim = exported.RootPrims.Single(); + Assert.That(exportedPrim.VariantSets, Has.Count.EqualTo(1)); + UsdVariantSet exportedSet = exportedPrim.VariantSets[0]; + Assert.That(exportedSet.SetName, Is.EqualTo("lod")); + Assert.That(exportedSet.Selection, Is.EqualTo("high")); + + // Every authored branch is recovered, in order, with its body content. + Assert.That(exportedSet.Variants.Select(v => v.Name), Is.EqualTo(new[] { "high", "low" })); + Assert.That(exportedSet.Variants[0].Attributes.Single().Name, Is.EqualTo("resolution")); + + // The materialized branches must not be re-exported as child prims (ยง7.4). + Assert.That(exportedPrim.Children, Is.Empty); + } + + // ---- helpers ------------------------------------------------------------------- + + private static (MaterializedScene Scene, UsdVariantSetState Set) MaterializeSet( + UsdVariantSet variantSet) + { + var stage = new UsdStage("V") { DefaultPrim = "P" }; + var prim = new UsdPrim("P", "Xform"); + prim.VariantSets.Add(variantSet); + stage.AddRootPrim(prim); + + MaterializedScene ms = MaterializationHarness.Materialize(stage); + FolderState folder = ms.Prim("/P").VariantSets!; + UsdVariantSetState set = + MaterializationHarness.ChildrenOfType(ms.Context, folder).Single(); + return (ms, set); + } + } +} diff --git a/tests/Opc.Ua.OpenUsd.Tests/VariantConversionsTests.cs b/tests/Opc.Ua.OpenUsd.Tests/VariantConversionsTests.cs new file mode 100644 index 0000000000..8d9c1b09e3 --- /dev/null +++ b/tests/Opc.Ua.OpenUsd.Tests/VariantConversionsTests.cs @@ -0,0 +1,253 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; + +namespace Opc.Ua.OpenUsd.Client.Tests +{ + /// + /// Unit tests for , the widening accessors every + /// OpenUSD binding conversion funnels through. They must accept each OPC UA numeric + /// built-in without boxing and degrade to false โ€” never throw โ€” for a source + /// whose built-in type does not widen. + /// + [TestFixture] + [Category("OpenUsd")] + public sealed class VariantConversionsTests + { + [Test] + public void TryGetDoubleReadsADoubleSource() + { + Assert.That(VariantConversions.TryGetDouble(new Variant(1.5), out double result), Is.True); + Assert.That(result, Is.EqualTo(1.5).Within(1e-12)); + } + + [Test] + public void TryGetDoubleWidensAFloatSource() + { + Assert.That(VariantConversions.TryGetDouble(new Variant(2.5f), out double result), Is.True); + Assert.That(result, Is.EqualTo(2.5).Within(1e-6)); + } + + [Test] + public void TryGetDoubleWidensASignedIntegerSource() + { + Assert.That(VariantConversions.TryGetDouble(new Variant(-7), out double result), Is.True); + Assert.That(result, Is.EqualTo(-7.0).Within(1e-12)); + } + + [Test] + public void TryGetDoubleWidensAnUnsignedSixtyFourBitSource() + { + Assert.That( + VariantConversions.TryGetDouble(new Variant(ulong.MaxValue), out double result), + Is.True); + Assert.That(result, Is.EqualTo((double)ulong.MaxValue).Within(1.0)); + } + + [Test] + public void TryGetDoubleRejectsANonNumericSource() + { + Assert.That(VariantConversions.TryGetDouble(new Variant("41.0"), out double result), Is.False); + Assert.That(result, Is.Zero); + } + + [Test] + public void TryGetDoubleRejectsANullSource() + { + Assert.That(VariantConversions.TryGetDouble(default, out double result), Is.False); + Assert.That(result, Is.Zero); + } + + [Test] + public void TryGetInt64ReadsALongSource() + { + Assert.That( + VariantConversions.TryGetInt64(new Variant(long.MinValue), out long result), + Is.True); + Assert.That(result, Is.EqualTo(long.MinValue)); + } + + [Test] + public void TryGetInt64WidensAnIntSource() + { + Assert.That(VariantConversions.TryGetInt64(new Variant(-42), out long result), Is.True); + Assert.That(result, Is.EqualTo(-42L)); + } + + [Test] + public void TryGetInt64WidensAUIntSource() + { + Assert.That( + VariantConversions.TryGetInt64(new Variant(uint.MaxValue), out long result), + Is.True); + Assert.That(result, Is.EqualTo(4294967295L)); + } + + [Test] + public void TryGetInt64WidensAShortSource() + { + Assert.That( + VariantConversions.TryGetInt64(new Variant((short)-300), out long result), + Is.True); + Assert.That(result, Is.EqualTo(-300L)); + } + + [Test] + public void TryGetInt64WidensAUShortSource() + { + Assert.That( + VariantConversions.TryGetInt64(new Variant((ushort)65535), out long result), + Is.True); + Assert.That(result, Is.EqualTo(65535L)); + } + + [Test] + public void TryGetInt64WidensASByteSource() + { + Assert.That( + VariantConversions.TryGetInt64(new Variant((sbyte)-128), out long result), + Is.True); + Assert.That(result, Is.EqualTo(-128L)); + } + + [Test] + public void TryGetInt64WidensAByteSource() + { + Assert.That( + VariantConversions.TryGetInt64(new Variant((byte)255), out long result), + Is.True); + Assert.That(result, Is.EqualTo(255L)); + } + + [TestCase(true, 1L)] + [TestCase(false, 0L)] + public void TryGetInt64MapsABooleanSourceToZeroOrOne(bool source, long expected) + { + Assert.That(VariantConversions.TryGetInt64(new Variant(source), out long result), Is.True); + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void TryGetInt64RejectsAUInt64SourceBecauseItDoesNotFit() + { + Assert.That( + VariantConversions.TryGetInt64(new Variant(ulong.MaxValue), out long result), + Is.False); + Assert.That(result, Is.Zero); + } + + [Test] + public void TryGetInt64RejectsAFloatingPointSource() + { + Assert.That(VariantConversions.TryGetInt64(new Variant(1.5), out long result), Is.False); + Assert.That(result, Is.Zero); + } + + [Test] + public void TryGetInt64RejectsANullSource() + { + Assert.That(VariantConversions.TryGetInt64(default, out long result), Is.False); + Assert.That(result, Is.Zero); + } + + [TestCase(true)] + [TestCase(false)] + public void TryGetBooleanReadsABooleanSource(bool source) + { + Assert.That(VariantConversions.TryGetBoolean(new Variant(source), out bool result), Is.True); + Assert.That(result, Is.EqualTo(source)); + } + + [Test] + public void TryGetBooleanTreatsANonZeroNumericSourceAsTrue() + { + Assert.That(VariantConversions.TryGetBoolean(new Variant(-0.25), out bool result), Is.True); + Assert.That(result, Is.True); + } + + [Test] + public void TryGetBooleanTreatsAZeroNumericSourceAsFalse() + { + Assert.That(VariantConversions.TryGetBoolean(new Variant(0), out bool result), Is.True); + Assert.That(result, Is.False); + } + + [Test] + public void TryGetBooleanRejectsANonNumericSource() + { + Assert.That( + VariantConversions.TryGetBoolean(new Variant("true"), out bool result), + Is.False); + Assert.That(result, Is.False); + } + + [Test] + public void TryGetBytesReadsAByteStringSource() + { + var source = new ByteString(new byte[] { 1, 2, 3 }); + + Assert.That( + VariantConversions.TryGetBytes(new Variant(source), out ByteString result), + Is.True); + Assert.That(result.Length, Is.EqualTo(3)); + Assert.That(result.Span[0], Is.EqualTo((byte)1)); + Assert.That(result.Span[2], Is.EqualTo((byte)3)); + } + + [Test] + public void TryGetBytesCopiesAByteArraySource() + { + ArrayOf source = new byte[] { 9, 8, 7, 6 }; + + Assert.That( + VariantConversions.TryGetBytes(new Variant(source), out ByteString result), + Is.True); + Assert.That(result.Length, Is.EqualTo(4)); + Assert.That(result.Span[0], Is.EqualTo((byte)9)); + Assert.That(result.Span[3], Is.EqualTo((byte)6)); + } + + [Test] + public void TryGetBytesRejectsANonBinarySource() + { + Assert.That( + VariantConversions.TryGetBytes(new Variant(1.0), out ByteString result), + Is.False); + Assert.That(result.IsNull, Is.True); + } + + [Test] + public void TryGetBytesRejectsANullSource() + { + Assert.That(VariantConversions.TryGetBytes(default, out ByteString result), Is.False); + Assert.That(result.IsNull, Is.True); + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/GlobalPositioningClientTests.cs b/tests/Opc.Ua.Positioning.Tests/GlobalPositioningClientTests.cs new file mode 100644 index 0000000000..b5d83f9ce9 --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/GlobalPositioningClientTests.cs @@ -0,0 +1,506 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Moq; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.Client.Subscriptions; +using Opc.Ua.Client.Subscriptions.Streaming; +using Opc.Ua.Gpos; +using StreamingMonitoredItemOptions = + Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions; + +namespace Opc.Ua.Positioning.Tests +{ + [TestFixture] + [Category("Positioning")] + public sealed class GlobalPositioningClientTests + { + [Test] + public async Task ObserveGlobalPositionWithTypedValueReturnsMetadataAsync() + { + Mock sessionMock = CreateSessionMock(); + NodeId positionId = new("Position", 2); + NodeId sourcePropertyId = new("SourceId", 2); + NodeId crsId = new("CoordinateReferenceSystem", 2); + NodeId zoneId = new("Zone", 2); + GlobalPositionDataType position = new() + { + Longitude = 8.55, + Latitude = 47.37, + Elevation = 408.2, + EncodingMask = (uint)S3DGeographicCoordinateDataTypeFields.Elevation + }; + + SetupTranslateReturns( + sessionMock, + new Dictionary + { + [BuildPathKey( + positionId, + ReferenceTypeIds.HasProperty, + "SourceId")] = sourcePropertyId, + [BuildPathKey( + positionId, + ReferenceTypeIds.HasComponent, + "CoordinateReferenceSystem")] = crsId + }); + SetupReadReturns( + sessionMock, + nodeId => + { + if (nodeId == sourcePropertyId) + { + return new DataValue(new Variant(zoneId), StatusCodes.Good); + } + + if (nodeId == crsId) + { + return new DataValue(new Variant((uint)4326), StatusCodes.Good); + } + + throw new AssertionException($"Unexpected read for '{nodeId}'."); + }); + + ITelemetryContext telemetry = Mock.Of(); + var client = new Client.GlobalPositioningClient( + sessionMock.Object, + telemetry); + + Client.GlobalPositionValue observed = + await ReadSingleAsync( + client.ObserveGlobalPositionAsync( + positionId, + new SingleValueStreamingSubscription( + new DataValue( + Variant.From(new ExtensionObject(position)), + StatusCodes.Good)))) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(client.Telemetry, Is.SameAs(telemetry)); + Assert.That(client.OpenZone(zoneId), Is.Not.Null); + Assert.That(observed.NodeId, Is.EqualTo(positionId)); + Assert.That(observed.SourceNodeId, Is.EqualTo(zoneId)); + Assert.That(observed.CoordinateReferenceSystem, Is.EqualTo(4326)); + Assert.That(observed.Position.Longitude, Is.EqualTo(8.55)); + Assert.That(observed.Position.Latitude, Is.EqualTo(47.37)); + Assert.That(observed.StatusCode, Is.EqualTo(StatusCodes.Good)); + }); + } + + [Test] + public void ObserveGlobalPositionWithWrongValueTypeThrowsBadTypeMismatch() + { + Mock sessionMock = CreateSessionMock(); + NodeId positionId = new("Position", 2); + NodeId sourcePropertyId = new("SourceId", 2); + NodeId crsId = new("CoordinateReferenceSystem", 2); + NodeId zoneId = new("Zone", 2); + + SetupTranslateReturns( + sessionMock, + new Dictionary + { + [BuildPathKey( + positionId, + ReferenceTypeIds.HasProperty, + "SourceId")] = sourcePropertyId, + [BuildPathKey( + positionId, + ReferenceTypeIds.HasComponent, + "CoordinateReferenceSystem")] = crsId + }); + SetupReadReturns( + sessionMock, + nodeId => + { + if (nodeId == sourcePropertyId) + { + return new DataValue(new Variant(zoneId), StatusCodes.Good); + } + + if (nodeId == crsId) + { + return new DataValue(new Variant((uint)4326), StatusCodes.Good); + } + + throw new AssertionException($"Unexpected read for '{nodeId}'."); + }); + + var client = new Client.GlobalPositioningClient( + sessionMock.Object, + Mock.Of()); + + ServiceResultException? exception = Assert.ThrowsAsync( + async () => await ReadSingleAsync( + client.ObserveGlobalPositionAsync( + positionId, + new SingleValueStreamingSubscription( + new DataValue(Variant.From("wrong"), StatusCodes.Good)))) + .ConfigureAwait(false)); + + Assert.That(exception!.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); + } + + [Test] + public async Task ReadGlobalLocationRetriesAnIncoherentMovingSnapshotAsync() + { + Mock sessionMock = CreateSessionMock(); + NodeId locationId = new("Location", 2); + NodeId positionId = new("Position", 2); + NodeId sourcePropertyId = new("SourceId", 2); + NodeId crsId = new("CoordinateReferenceSystem", 2); + NodeId zoneId = new("Zone", 2); + GlobalLocationDataType first = CreateLocation(8.0); + GlobalLocationDataType second = CreateLocation(9.0); + int aggregateReads = 0; + + SetupTranslateReturns( + sessionMock, + new Dictionary + { + [BuildPathKey( + locationId, + ReferenceTypeIds.HasComponent, + "Position")] = positionId, + [BuildPathKey( + positionId, + ReferenceTypeIds.HasProperty, + "SourceId")] = sourcePropertyId, + [BuildPathKey( + positionId, + ReferenceTypeIds.HasComponent, + "CoordinateReferenceSystem")] = crsId + }); + SetupReadReturns( + sessionMock, + nodeId => + { + if (nodeId == locationId) + { + GlobalLocationDataType value = + aggregateReads++ == 0 ? first : second; + return new DataValue( + Variant.From(new ExtensionObject(value)), + StatusCodes.Good); + } + + if (nodeId == positionId) + { + return new DataValue( + Variant.From(new ExtensionObject(second.Position)), + StatusCodes.Good); + } + + if (nodeId == sourcePropertyId) + { + return new DataValue(new Variant(zoneId), StatusCodes.Good); + } + + if (nodeId == crsId) + { + return new DataValue(new Variant((uint)4326), StatusCodes.Good); + } + + throw new AssertionException($"Unexpected read for '{nodeId}'."); + }); + + var client = new Client.GlobalPositioningClient( + sessionMock.Object, + Mock.Of()); + Client.GlobalLocationValue value = + await client.ReadGlobalLocationAsync(locationId) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(aggregateReads, Is.EqualTo(2)); + Assert.That(value.Location.Position.Longitude, Is.EqualTo(9.0)); + Assert.That(value.SourceNodeId, Is.EqualTo(zoneId)); + }); + } + + [Test] + public void ReadGlobalLocationWithPersistentMismatchThrowsBadDataEncodingInvalid() + { + Mock sessionMock = CreateSessionMock(); + NodeId locationId = new("Location", 2); + NodeId positionId = new("Position", 2); + GlobalLocationDataType aggregate = CreateLocation(8.0); + GlobalLocationDataType component = CreateLocation(9.0); + + SetupTranslateReturns( + sessionMock, + new Dictionary + { + [BuildPathKey( + locationId, + ReferenceTypeIds.HasComponent, + "Position")] = positionId + }); + SetupReadReturns( + sessionMock, + nodeId => + { + if (nodeId == locationId) + { + return new DataValue( + Variant.From(new ExtensionObject(aggregate)), + StatusCodes.Good); + } + + if (nodeId == positionId) + { + return new DataValue( + Variant.From(new ExtensionObject(component.Position)), + StatusCodes.Good); + } + + throw new AssertionException($"Unexpected read for '{nodeId}'."); + }); + + var client = new Client.GlobalPositioningClient( + sessionMock.Object, + Mock.Of()); + + ServiceResultException? exception = Assert.ThrowsAsync( + async () => await client.ReadGlobalLocationAsync(locationId) + .ConfigureAwait(false)); + + Assert.That( + exception!.StatusCode, + Is.EqualTo(StatusCodes.BadDataEncodingInvalid)); + } + + private static GlobalLocationDataType CreateLocation(double longitude) + { + return new GlobalLocationDataType + { + Position = new GlobalPositionDataType + { + Longitude = longitude, + Latitude = 47.0 + } + }; + } + + private static Mock CreateSessionMock() + { + var sessionMock = new Mock(); + var context = ServiceMessageContext.Create(null); + context.NamespaceUris.GetIndexOrAppend(Gpos.Namespaces.GPOS); + context.NamespaceUris.GetIndexOrAppend(Rsl.Namespaces.RSL); + sessionMock.SetupGet(s => s.NamespaceUris).Returns(context.NamespaceUris); + sessionMock.SetupGet(s => s.MessageContext).Returns(context); + sessionMock.SetupGet(s => s.Factory).Returns(context.Factory); + sessionMock.SetupGet(s => s.OperationLimits).Returns(new OperationLimits()); + sessionMock.SetupGet(s => s.ServerCapabilities) + .Returns(new ServerCapabilities()); + sessionMock.SetupGet(s => s.ContinuationPointPolicy) + .Returns(ContinuationPointPolicy.Default); + sessionMock.SetupGet(s => s.NodeCache) + .Returns(new Mock(MockBehavior.Loose).Object); + return sessionMock; + } + + private static async Task ReadSingleAsync(IAsyncEnumerable source) + { + await foreach (T value in source.ConfigureAwait(false)) + { + return value; + } + + throw new AssertionException("The async sequence produced no value."); + } + + private static string BuildPathKey( + NodeId parentId, + NodeId referenceTypeId, + string browseName) + { + return $"{parentId}|{referenceTypeId}|{browseName}"; + } + + private static void SetupTranslateReturns( + Mock sessionMock, + Dictionary targets) + { + sessionMock + .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync( + ( + RequestHeader? _, + ArrayOf browsePaths, + CancellationToken _ + ) => + { + var results = new BrowsePathResult[browsePaths.Count]; + for (int i = 0; i < browsePaths.Count; i++) + { + BrowsePath path = browsePaths[i]; + RelativePathElement element = path.RelativePath.Elements[0]; + if (!targets.TryGetValue( + BuildPathKey( + path.StartingNode, + element.ReferenceTypeId, + element.TargetName.Name ?? string.Empty), + out NodeId targetId)) + { + results[i] = new BrowsePathResult + { + StatusCode = StatusCodes.BadNoMatch + }; + continue; + } + + results[i] = new BrowsePathResult + { + StatusCode = StatusCodes.Good, + Targets = + [ + new BrowsePathTarget + { + TargetId = new ExpandedNodeId(targetId), + RemainingPathIndex = uint.MaxValue + } + ] + }; + } + + return new TranslateBrowsePathsToNodeIdsResponse + { + Results = results + }; + }); + } + + private static void SetupReadReturns( + Mock sessionMock, + Func valueFactory) + { + sessionMock + .Setup(s => s.ReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync( + ( + RequestHeader? _, + double _, + TimestampsToReturn _, + ArrayOf nodesToRead, + CancellationToken _ + ) => + { + var results = new DataValue[nodesToRead.Count]; + for (int i = 0; i < nodesToRead.Count; i++) + { + results[i] = valueFactory(nodesToRead[i].NodeId); + } + + return new ReadResponse + { + Results = results, + DiagnosticInfos = [] + }; + }); + } + + private sealed class SingleValueStreamingSubscription : + IStreamingSubscription + { + private readonly DataValue m_value; + + public SingleValueStreamingSubscription(DataValue value) + { + m_value = value; + } + + public ValueTask DisposeAsync() + { + return default; + } + + public IAsyncEnumerable SubscribeDataChangesAsync( + NodeId nodeId, + StreamingMonitoredItemOptions? options = null, + CancellationToken ct = default) + { + return YieldDataChangeAsync(ct); + } + + public IAsyncEnumerable SubscribeDataChangesAsync( + IReadOnlyList nodeIds, + StreamingMonitoredItemOptions? options = null, + CancellationToken ct = default) + { + return YieldDataChangeAsync(ct); + } + + public IAsyncEnumerable SubscribeEventsAsync( + NodeId notifierId, + EventFilter filter, + StreamingMonitoredItemOptions? options = null, + CancellationToken ct = default) + { + return YieldNoEventsAsync(ct); + } + + private async IAsyncEnumerable YieldDataChangeAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return new DataValueChange(null, m_value, null); + } + + private static async IAsyncEnumerable + YieldNoEventsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield break; + } + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/ModelEncodingTests.cs b/tests/Opc.Ua.Positioning.Tests/ModelEncodingTests.cs new file mode 100644 index 0000000000..7e11360dff --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/ModelEncodingTests.cs @@ -0,0 +1,189 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.IO; +using NUnit.Framework; +using Opc.Ua.Gpos; + +namespace Opc.Ua.Positioning.Tests +{ + [TestFixture] + [Category("Positioning")] + public sealed class ModelEncodingTests + { + [Test] + public void GeographicCoordinateRoundTripsBinaryAndJson() + { + var value = new S3DGeographicCoordinateDataType + { + EncodingMask = + (uint)S3DGeographicCoordinateDataTypeFields.Elevation, + Longitude = 8.55, + Latitude = 47.37, + Elevation = 408.2 + }; + + AssertRoundTrips(value); + } + + [Test] + public void GlobalPositionRoundTripsBinaryAndJson() + { + var value = new GlobalPositionDataType + { + EncodingMask = + (uint)S3DGeographicCoordinateDataTypeFields.Elevation | + (uint)GlobalPositionDataTypeFields.Accuracy | + (uint)GlobalPositionDataTypeFields.Floor, + Longitude = 8.55, + Latitude = 47.37, + Elevation = 408.2, + Accuracy = 0.04, + Floor = 2.0f + }; + + AssertRoundTrips(value); + } + + [Test] + public void GlobalPositionBinaryEncodingUsesSingleOptionalFieldMask() + { + var value = new GlobalPositionDataType + { + EncodingMask = + (uint)GlobalPositionDataTypeFields.Elevation | + (uint)GlobalPositionDataTypeFields.Accuracy | + (uint)GlobalPositionDataTypeFields.Floor, + Longitude = 8.55, + Latitude = 47.37, + Elevation = 408.2, + Accuracy = 0.04, + Floor = 2.0f + }; + ServiceMessageContext context = CreateContext(); + + using var stream = new MemoryStream(); + using (var encoder = new BinaryEncoder(stream, context, true)) + { + encoder.WriteEncodeable("Value", value); + } + + Assert.That(stream.Length, Is.EqualTo(40)); + } + + [Test] + public void GlobalLocationRoundTripsBinaryAndJson() + { + var value = new GlobalLocationDataType + { + EncodingMask = + (uint)GlobalLocationDataTypeFields.Orientation, + Position = new GlobalPositionDataType + { + EncodingMask = + (uint)S3DGeographicCoordinateDataTypeFields.Elevation, + Longitude = 8.55, + Latitude = 47.37, + Elevation = 408.2 + }, + Orientation = new ThreeDOrientation + { + A = 1.0, + B = 2.0, + C = 3.0 + } + }; + + AssertRoundTrips(value); + } + + [Test] + public void GroundControlPointRoundTripsBinaryAndJson() + { + var value = new GroundControlPointDataType + { + GlobalPosition = new S3DGeographicCoordinateDataType + { + EncodingMask = + (uint)S3DGeographicCoordinateDataTypeFields.Elevation, + Longitude = 8.55, + Latitude = 47.37, + Elevation = 408.2 + }, + LocalPosition = new ThreeDCartesianCoordinates + { + X = 1.0, + Y = 2.0, + Z = 3.0 + } + }; + + AssertRoundTrips(value); + } + + private static void AssertRoundTrips(T value) + where T : IEncodeable, new() + { + ServiceMessageContext context = CreateContext(); + + using var stream = new MemoryStream(); + using (var encoder = new BinaryEncoder(stream, context, true)) + { + encoder.WriteEncodeable("Value", value); + } + stream.Position = 0; + using (var decoder = new BinaryDecoder(stream, context, true)) + { + T decoded = decoder.ReadEncodeable("Value"); + Assert.That(value.IsEqual(decoded), Is.True); + } + + string json; + using (var encoder = new JsonEncoder(context)) + { + encoder.EncodeMessage(value, value.TypeId); + json = encoder.CloseAndReturnText(); + } + using (var decoder = new JsonDecoder(json, context)) + { + T decoded = decoder.DecodeMessage(); + Assert.That(value.IsEqual(decoded), Is.True); + } + } + + private static ServiceMessageContext CreateContext() + { + var context = ServiceMessageContext.Create(null); + context.NamespaceUris.GetIndexOrAppend(Rsl.Namespaces.RSL); + context.NamespaceUris.GetIndexOrAppend(Gpos.Namespaces.GPOS); + context.Factory.Builder.AddOpcUaGpos().Commit(); + return context; + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/ModelGenerationTests.cs b/tests/Opc.Ua.Positioning.Tests/ModelGenerationTests.cs new file mode 100644 index 0000000000..481099017c --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/ModelGenerationTests.cs @@ -0,0 +1,215 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.Gpos; +using Opc.Ua.Rsl; + +namespace Opc.Ua.Positioning.Tests +{ + [TestFixture] + [Category("Positioning")] + public sealed class ModelGenerationTests + { + [Test] + public void GeneratedNamespacesAndIdentifiersMatchSpecifications() + { + Assert.Multiple(() => + { + Assert.That( + Rsl.Namespaces.RSL, + Is.EqualTo("http://opcfoundation.org/UA/RSL/")); + Assert.That( + Gpos.Namespaces.GPOS, + Is.EqualTo("http://opcfoundation.org/UA/GPOS/")); + Assert.That(Rsl.ObjectTypes.SpatialObjectType, Is.EqualTo(1002)); + Assert.That(Rsl.Objects.RelativeSpatialLocations, Is.EqualTo(5001)); + Assert.That(Gpos.ObjectTypes.ZoneType, Is.EqualTo(1005)); + Assert.That(Gpos.Objects.GlobalLocations, Is.EqualTo(5013)); + }); + } + + [Test] + public void GeneratedLoadersPopulateBothModels() + { + var context = new SystemContext(telemetry: null!) + { + NamespaceUris = new NamespaceTable() + }; + context.NamespaceUris.GetIndexOrAppend(Rsl.Namespaces.RSL); + context.NamespaceUris.GetIndexOrAppend(Gpos.Namespaces.GPOS); + var nodes = new NodeStateCollection(); + + nodes.AddOpcUaRsl(context); + int rslCount = CountNodes(context, nodes); + nodes.AddOpcUaGpos(context); + int totalCount = CountNodes(context, nodes); + + Assert.Multiple(() => + { + Assert.That(rslCount, Is.EqualTo(46)); + Assert.That(totalCount, Is.EqualTo(94)); + Assert.That( + nodes.Any(node => node.NodeId == NodeId.Create( + Rsl.Objects.RelativeSpatialLocations, + Rsl.Namespaces.RSL, + context.NamespaceUris)), + Is.True); + Assert.That( + nodes.Any(node => node.NodeId == NodeId.Create( + Gpos.Objects.GlobalLocations, + Gpos.Namespaces.GPOS, + context.NamespaceUris)), + Is.True); + }); + } + + private static int CountNodes( + ISystemContext context, + NodeStateCollection roots) + { + var visited = new HashSet(); + foreach (NodeState root in roots) + { + CountNode(context, root, visited); + } + return visited.Count; + } + + private static void CountNode( + ISystemContext context, + NodeState node, + HashSet visited) + { + if (!visited.Add(node.NodeId)) + { + return; + } + + var children = new List(); + node.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + CountNode(context, child, visited); + } + } + + [Test] + public void GeneratedFactoriesMaterializeMandatoryChildren() + { + var context = new SystemContext(telemetry: null!) + { + NamespaceUris = new NamespaceTable() + }; + context.NamespaceUris.GetIndexOrAppend(Rsl.Namespaces.RSL); + context.NamespaceUris.GetIndexOrAppend(Gpos.Namespaces.GPOS); + + CartesianFrameAngleOrientationState frame = + context.CreateInstanceOfCartesianFrameAngleOrientationType(); + GlobalLocationState location = + context.CreateInstanceOfGlobalLocationType(); + ZoneState zone = context.CreateInstanceOfZoneType(); + + Assert.Multiple(() => + { + Assert.That(frame.Base, Is.Not.Null); + Assert.That(frame.Position, Is.Not.Null); + Assert.That(frame.Orientation, Is.Not.Null); + Assert.That(location.Base, Is.Not.Null); + Assert.That(location.Position, Is.Not.Null); + Assert.That(location.Position!.Longitude, Is.Not.Null); + Assert.That(location.Position.Latitude, Is.Not.Null); + Assert.That(location.Position.SourceId, Is.Not.Null); + Assert.That(zone.ZoneId, Is.Not.Null); + }); + } + + [Test] + public void GposOptionalFieldsUseEncodingMasks() + { + var position = new GlobalPositionDataType + { + EncodingMask = + (uint)S3DGeographicCoordinateDataTypeFields.Elevation | + (uint)GlobalPositionDataTypeFields.Accuracy | + (uint)GlobalPositionDataTypeFields.Floor, + Longitude = 8.0, + Latitude = 47.0, + Elevation = 500.0, + Accuracy = 0.1, + Floor = 2.0f + }; + var location = new GlobalLocationDataType + { + EncodingMask = + (uint)GlobalLocationDataTypeFields.Orientation, + Position = position, + Orientation = new ThreeDOrientation { C = 90.0 } + }; + + Assert.Multiple(() => + { + Assert.That(position.Elevation, Is.EqualTo(500.0)); + Assert.That(position.Accuracy, Is.EqualTo(0.1)); + Assert.That(position.Floor, Is.EqualTo(2.0f)); + Assert.That(location.Position, Is.SameAs(position)); + Assert.That(location.Orientation.C, Is.EqualTo(90.0)); + }); + } + + [Test] + public void DerivedGlobalPositionSharesEncodingMaskWithGeographicBase() + { + var position = new GlobalPositionDataType + { + EncodingMask = + (uint)GlobalPositionDataTypeFields.Elevation | + (uint)GlobalPositionDataTypeFields.Accuracy, + Longitude = 8.0, + Latitude = 47.0, + Elevation = 500.0, + Accuracy = 0.1 + }; + S3DGeographicCoordinateDataType geographic = position; + + Assert.Multiple(() => + { + Assert.That( + geographic.EncodingMask, + Is.EqualTo(position.EncodingMask)); + Assert.That( + geographic.EncodingMask & + (uint)S3DGeographicCoordinateDataTypeFields.Elevation, + Is.Not.Zero); + }); + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/Opc.Ua.Positioning.Tests.csproj b/tests/Opc.Ua.Positioning.Tests/Opc.Ua.Positioning.Tests.csproj new file mode 100644 index 0000000000..802fdc962e --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/Opc.Ua.Positioning.Tests.csproj @@ -0,0 +1,38 @@ + + + Exe + $(TestsTargetFrameworks) + Opc.Ua.Positioning.Tests + Opc.Ua.Positioning.Tests + enable + false + false + $(NoWarn);CS1591;CA2007;CA2000;CA1014;CA1861 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/tests/Opc.Ua.Positioning.Tests/PositioningAddressSpaceBuilderTests.cs b/tests/Opc.Ua.Positioning.Tests/PositioningAddressSpaceBuilderTests.cs new file mode 100644 index 0000000000..1c16528909 --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/PositioningAddressSpaceBuilderTests.cs @@ -0,0 +1,331 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Gpos; +using Opc.Ua.Positioning.Server; +using Opc.Ua.Rsl; + +namespace Opc.Ua.Positioning.Tests +{ + [TestFixture] + [Category("Positioning")] + [NonParallelizable] + public sealed class PositioningAddressSpaceBuilderTests + { + private PositioningServerFixture? m_fixture; + + [SetUp] + public async Task SetUpAsync() + { + m_fixture = new PositioningServerFixture(); + await m_fixture.StartAsync().ConfigureAwait(false); + } + + [TearDown] + public async Task TearDownAsync() + { + if (m_fixture != null) + { + await m_fixture.DisposeAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task BuilderCreatesRslAndGposInstancesAsync() + { + PositioningNodeManager manager = m_fixture!.Manager; + PositioningAddressSpaceBuilder builder = + manager.CreatePositioningBuilder(); + ushort rslNamespaceIndex = (ushort)manager.Server.NamespaceUris + .GetIndex(Rsl.Namespaces.RSL); + var owner = new BaseObjectState(null) + { + NodeId = new NodeId("TrackedAsset", rslNamespaceIndex), + BrowseName = new QualifiedName("TrackedAsset", rslNamespaceIndex), + DisplayName = new LocalizedText("TrackedAsset"), + TypeDefinitionId = ObjectTypeIds.BaseObjectType + }; + await manager.AddPredefinedNodeAsync(owner).ConfigureAwait(false); + + EUInformation metres = new( + "m", + "metre", + "http://www.opcfoundation.org/UA/units/un/cefact"); + EUInformation degrees = new( + "deg", + "degree", + "http://www.opcfoundation.org/UA/units/un/cefact"); + var frame = new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates + { + X = 1.0, + Y = 2.0, + Z = 3.0 + }, + Orientation = new ThreeDOrientation { C = 45.0 } + }; + + SpatialObjectsListState list = builder.CreateSpatialObjectsList( + owner, + new QualifiedName("CellFrames", rslNamespaceIndex), + "cell", + new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates(), + Orientation = new ThreeDOrientation() + }, + metres, + degrees); + await builder.RegisterAsync(list).ConfigureAwait(false); + + SpatialObjectState spatialObject = builder.AttachSpatialObject( + owner, + list, + new QualifiedName("SpatialObject", rslNamespaceIndex), + "asset", + frame, + metres, + degrees); + CartesianFrameAngleOrientationState internalFrame = builder.AddInternalFrame( + spatialObject, + new QualifiedName("SensorFrame", rslNamespaceIndex), + spatialObject.PositionFrame!.NodeId, + frame, + metres, + degrees); + CartesianFrameAngleOrientationState alternativeFrame = builder.AddAlternativeFrame( + spatialObject, + new QualifiedName("SurveyFrame", rslNamespaceIndex), + spatialObject.PositionFrame.NodeId, + frame, + metres, + degrees); + await builder.RegisterAsync(spatialObject).ConfigureAwait(false); + + var global = new S3DGeographicCoordinateDataType + { + EncodingMask = + (uint)S3DGeographicCoordinateDataTypeFields.Elevation, + Longitude = 8.0, + Latitude = 47.0, + Elevation = 500.0 + }; + var controlPoint = new GroundControlPointDataType + { + GlobalPosition = global, + LocalPosition = new ThreeDCartesianCoordinates() + }; + ZoneState zone = builder.CreateZone( + new QualifiedName("CellZone", rslNamespaceIndex), + "cell-zone", + ArrayOf.Create([controlPoint])); + await builder.RegisterAsync(zone).ConfigureAwait(false); + + GlobalLocationState location = builder.AttachGlobalLocation( + owner, + new QualifiedName("GlobalLocation", rslNamespaceIndex), + zone.NodeId, + 4326); + await builder.RegisterAsync(location).ConfigureAwait(false); + GlobalPositionState position = builder.AttachGlobalPosition( + owner, + new QualifiedName("GlobalPosition", rslNamespaceIndex), + zone.NodeId, + 4326); + await builder.RegisterAsync(position).ConfigureAwait(false); + var sample = new GeoLocationSample( + new GeoPosition(47.0, 8.0, EpsgCode: 4326), + null, + default, + StatusCodes.Good, + DateTimeUtc.Now, + "asset"); + builder.SetGlobalPositionValue(position, sample); + + ZoneState proximityZone = builder.CreateProximityZone( + new QualifiedName("ProximityZone", rslNamespaceIndex), + "proximity-zone", + global, + 2.5); + await builder.RegisterAsync(proximityZone).ConfigureAwait(false); + var positionFrameState = + (CartesianFrameAngleOrientationState)spatialObject.PositionFrame!; + var worldFrameState = + (CartesianFrameAngleOrientationState)list.WorldFrame!; + + Assert.Multiple(() => + { + Assert.That(list.WorldFrame, Is.TypeOf()); + Assert.That(list.NodeVersion!.Value, Is.EqualTo("2")); + Assert.That( + worldFrameState.Position!.X!.Value, + Is.Zero); + Assert.That(spatialObject.PositionFrame, + Is.TypeOf()); + Assert.That(spatialObject.PositionFrame!.Base!.Value, + Is.EqualTo(list.WorldFrame!.NodeId)); + Assert.That(positionFrameState.Position!.X!.Value, + Is.EqualTo(frame.CartesianCoordinates.X)); + Assert.That(positionFrameState.Orientation!.C!.Value, + Is.EqualTo(frame.Orientation.C)); + Assert.That(internalFrame.Base!.Value, + Is.EqualTo(spatialObject.PositionFrame.NodeId)); + Assert.That(internalFrame.Position!.X!.Value, + Is.EqualTo(frame.CartesianCoordinates.X)); + Assert.That(alternativeFrame.Base!.Value, + Is.EqualTo(spatialObject.PositionFrame.NodeId)); + Assert.That(zone.ZoneId!.Value, Is.EqualTo("cell-zone")); + Assert.That(zone.GroundControlPoints!.Value.Count, Is.EqualTo(1)); + Assert.That(location.Position!.SourceId!.Value, + Is.EqualTo(zone.NodeId)); + Assert.That(location.Position.CoordinateReferenceSystem!.Value, + Is.EqualTo(4326)); + Assert.That(position.Value.Longitude, Is.EqualTo(8.0)); + Assert.That(position.SourceId!.Value, Is.EqualTo(zone.NodeId)); + Assert.That(proximityZone.Position!.Value, Is.SameAs(global)); + Assert.That(proximityZone.Radius!.Value, Is.EqualTo(2.5)); + }); + } + + [Test] + public async Task BuilderRejectsInvalidAndDuplicateValuesAsync() + { + PositioningNodeManager manager = m_fixture!.Manager; + PositioningAddressSpaceBuilder builder = + manager.CreatePositioningBuilder(); + ushort namespaceIndex = (ushort)manager.Server.NamespaceUris + .GetIndex(Rsl.Namespaces.RSL); + var owner = new BaseObjectState(null) + { + NodeId = new NodeId("ValidationOwner", namespaceIndex), + BrowseName = new QualifiedName("ValidationOwner", namespaceIndex), + DisplayName = new LocalizedText("ValidationOwner"), + TypeDefinitionId = ObjectTypeIds.BaseObjectType + }; + await manager.AddPredefinedNodeAsync(owner).ConfigureAwait(false); + EUInformation metres = new( + "m", + "metre", + "http://www.opcfoundation.org/UA/units/un/cefact"); + EUInformation degrees = new( + "deg", + "degree", + "http://www.opcfoundation.org/UA/units/un/cefact"); + var frame = new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates(), + Orientation = new ThreeDOrientation() + }; + + _ = builder.CreateSpatialObjectsList( + owner, + new QualifiedName("Frames", namespaceIndex), + "frames", + frame, + metres, + degrees); + + Assert.Multiple(() => + { + Assert.That( + () => builder.CreateSpatialObjectsList( + owner, + new QualifiedName("Frames", namespaceIndex), + "duplicate", + frame, + metres, + degrees), + Throws.TypeOf()); + Assert.That( + () => builder.AttachGlobalPosition( + owner, + new QualifiedName("Position", namespaceIndex), + NodeId.Null, + 4326), + Throws.TypeOf()); + Assert.That( + () => builder.CreateProximityZone( + new QualifiedName("InvalidZone", namespaceIndex), + "invalid-zone", + new S3DGeographicCoordinateDataType + { + Longitude = 181.0, + Latitude = 47.0 + }, + 1.0), + Throws.TypeOf()); + }); + } + + [Test] + public void FactoryAdvertisesBothNamespaces() + { + var factory = new PositioningNodeManagerFactory(); + Assert.That( + factory.NamespacesUris.ToArray(), + Is.EquivalentTo( + [ + Rsl.Namespaces.RSL, + Gpos.Namespaces.GPOS + ])); + } + + [Test] + public async Task NodeManagerPublishesRslAndGposNamespaceMetadataAsync() + { + PositioningNodeManager manager = m_fixture!.Manager; + NamespaceMetadataState? rsl = await manager.Server.NodeManager + .ConfigurationNodeManager! + .GetNamespaceMetadataStateAsync(Opc.Ua.Rsl.Namespaces.RSL) + .ConfigureAwait(false); + NamespaceMetadataState? gpos = await manager.Server.NodeManager + .ConfigurationNodeManager! + .GetNamespaceMetadataStateAsync(Opc.Ua.Gpos.Namespaces.GPOS) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(rsl, Is.Not.Null); + Assert.That(gpos, Is.Not.Null); + Assert.That(rsl!.BrowseName.NamespaceIndex, + Is.EqualTo(manager.Server.NamespaceUris.GetIndex( + Opc.Ua.Rsl.Namespaces.RSL))); + Assert.That(gpos!.BrowseName.NamespaceIndex, + Is.EqualTo(manager.Server.NamespaceUris.GetIndex( + Opc.Ua.Gpos.Namespaces.GPOS))); + Assert.That(rsl.NamespaceVersion!.Value, Is.EqualTo("1.00.1")); + Assert.That(gpos.NamespaceVersion!.Value, Is.EqualTo("1.0.0")); + }); + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/PositioningHostingTests.cs b/tests/Opc.Ua.Positioning.Tests/PositioningHostingTests.cs new file mode 100644 index 0000000000..ca256376e2 --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/PositioningHostingTests.cs @@ -0,0 +1,205 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.Positioning.Client; +using Opc.Ua.Positioning.Server; +using Opc.Ua.Positioning.Server.Hosting; +using Opc.Ua.Server.FileSystem; +using Opc.Ua.Server.Hosting; + +namespace Opc.Ua.Positioning.Tests +{ + [TestFixture] + [Category("Positioning")] + [NonParallelizable] + public sealed class PositioningHostingTests + { + [Test] + public void StandaloneRegistrationAddsFactoryProvidersAndRejectsDuplicates() + { + var services = new ServiceCollection(); + IOpcUaServerBuilder server = services + .AddOpcUa() + .AddServer(options => + { + options.ApplicationName = "PositioningHostingTest"; + options.AutoAcceptUntrustedCertificates = true; + }); + IPositioningServerBuilder positioning = server + .AddPositioningServer(); + var globalProvider = new TestGlobalProvider(); + var relativeProvider = new TestRelativeProvider(); + positioning + .AddGeoLocationProvider(_ => globalProvider) + .AddRelativeSpatialLocationProvider(_ => relativeProvider); + + using ServiceProvider provider = services.BuildServiceProvider(); + PositioningNodeManagerFactory factory = + provider.GetRequiredService(); + ArrayOf globalProviders = provider + .GetServices() + .ToArray() + .ToArrayOf(); + ArrayOf relativeProviders = + provider + .GetServices() + .ToArray() + .ToArrayOf(); + + Assert.Multiple(() => + { + Assert.That(positioning.Services, Is.SameAs(services)); + Assert.That(factory, Is.Not.Null); + Assert.That(globalProviders, Has.Count.EqualTo(1)); + Assert.That(relativeProviders, Has.Count.EqualTo(1)); + Assert.That( + () => server.AddPositioningServer(), + Throws.TypeOf()); + }); + } + + [Test] + public async Task CompositeRegistrationRunsOnlyMatchingConfiguratorAsync() + { + await using var fixture = new PositioningServerFixture(); + await fixture.StartAsync().ConfigureAwait(false); + var services = new ServiceCollection(); + int matchingCalls = 0; + int otherCalls = 0; + IPositioningServerBuilder positioning = services + .AddOpcUa() + .AddServer(options => + { + options.ApplicationName = "PositioningCompositeTest"; + options.AutoAcceptUntrustedCertificates = true; + }) + .AddPositioningFor(); + positioning + .ConfigurePositioningFor(_ => + { + matchingCalls++; + return default; + }) + .ConfigurePositioningFor(_ => + { + otherCalls++; + return default; + }); + + using ServiceProvider provider = services.BuildServiceProvider(); + IPositioningPostSetupRunner runner = + provider.GetRequiredService(); + await runner.RunAsync( + fixture.Manager, + CancellationToken.None).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(matchingCalls, Is.EqualTo(1)); + Assert.That(otherCalls, Is.Zero); + }); + } + + [Test] + public void ClientRegistrationAddsBothManagedSessionFactories() + { + var services = new ServiceCollection(); + var configuration = new ApplicationConfiguration( + DefaultTelemetry.Create(_ => { })) + { + ApplicationName = "PositioningClientHostingTest", + ApplicationType = ApplicationType.Client, + ClientConfiguration = new ClientConfiguration(), + SecurityConfiguration = new SecurityConfiguration(), + TransportQuotas = new TransportQuotas() + }; + services.AddOpcUa() + .AddClient(options => options.Configuration = configuration) + .AddPositioningClient(); + + using ServiceProvider provider = services.BuildServiceProvider(); + Func> + relativeFactory = provider.GetRequiredService< + Func>>(); + Func> + globalFactory = provider.GetRequiredService< + Func>>(); + + Assert.Multiple(() => + { + Assert.That(relativeFactory, Is.Not.Null); + Assert.That(globalFactory, Is.Not.Null); + }); + } + + private sealed class TestGlobalProvider : IGeoLocationProvider + { + public bool SupportsPush => true; + + public ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public IAsyncEnumerable WatchAsync( + string sourceId, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + } + + private sealed class TestRelativeProvider : + IRelativeSpatialLocationProvider + { + public ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public IAsyncEnumerable WatchAsync( + string sourceId, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/PositioningProviderTests.cs b/tests/Opc.Ua.Positioning.Tests/PositioningProviderTests.cs new file mode 100644 index 0000000000..4eedc410d1 --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/PositioningProviderTests.cs @@ -0,0 +1,540 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Concurrent; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Gpos; +using Opc.Ua.Positioning.Server; +using Opc.Ua.Rsl; + +namespace Opc.Ua.Positioning.Tests +{ + [TestFixture] + [Category("Positioning")] + [NonParallelizable] + public sealed class PositioningProviderTests + { + private PositioningServerFixture? m_fixture; + private PositioningAddressSpaceBuilder m_builder = null!; + private BaseObjectState m_owner = null!; + private ushort m_namespaceIndex; + + [SetUp] + public async Task SetUpAsync() + { + m_fixture = new PositioningServerFixture(); + await m_fixture.StartAsync().ConfigureAwait(false); + m_builder = m_fixture.Manager.CreatePositioningBuilder(); + m_namespaceIndex = (ushort)m_fixture.Manager.Server.NamespaceUris + .GetIndex(Rsl.Namespaces.RSL); + m_owner = new BaseObjectState(null) + { + NodeId = new NodeId("ProviderOwner", m_namespaceIndex), + BrowseName = new QualifiedName("ProviderOwner", m_namespaceIndex), + DisplayName = new LocalizedText("ProviderOwner"), + TypeDefinitionId = ObjectTypeIds.BaseObjectType + }; + await m_fixture.Manager.AddPredefinedNodeAsync(m_owner) + .ConfigureAwait(false); + } + + [TearDown] + public async Task TearDownAsync() + { + if (m_fixture != null) + { + await m_fixture.DisposeAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task GlobalProviderAppliesUpdatesAndCancelsAsync() + { + GlobalLocationState state = await CreateGlobalLocationAsync() + .ConfigureAwait(false); + using var provider = new ControlledGlobalProvider( + CreateGlobalSample("robot", 8.0, includeOptionalFields: true)); + PositioningProviderSubscription subscription = + await m_builder.BindGlobalLocationAsync( + state, + provider, + "robot").ConfigureAwait(false); + + provider.Publish( + CreateGlobalSample("robot", 9.0, includeOptionalFields: false)); + await WaitUntilAsync( + () => state.Value.Position.Longitude == 9.0) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(state.Value.Position.Longitude, Is.EqualTo(9.0)); + Assert.That(state.Position!.Longitude!.Value, Is.EqualTo(9.0)); + Assert.That( + state.Position.Elevation!.StatusCode, + Is.EqualTo(StatusCodes.BadNoData)); + Assert.That( + state.Orientation!.StatusCode, + Is.EqualTo(StatusCodes.BadNoData)); + Assert.That( + state.Orientation.A!.StatusCode, + Is.EqualTo(StatusCodes.BadNoData)); + Assert.That( + state.Orientation.B!.StatusCode, + Is.EqualTo(StatusCodes.BadNoData)); + Assert.That( + state.Orientation.C!.StatusCode, + Is.EqualTo(StatusCodes.BadNoData)); + Assert.That(state.Orientation.C!.Value, Is.EqualTo(90.0)); + }); + + await subscription.DisposeAsync().ConfigureAwait(false); + Task completed = await Task.WhenAny( + provider.CancellationObserved, + Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(false); + Assert.That(completed, Is.SameAs(provider.CancellationObserved)); + } + + [Test] + public async Task GlobalProviderFailurePreservesLastValueAndMarksStatusBadAsync() + { + GlobalLocationState state = await CreateGlobalLocationAsync() + .ConfigureAwait(false); + var provider = new FailingGlobalProvider( + CreateGlobalSample("robot", 8.0, includeOptionalFields: true), + CreateGlobalSample("robot", 9.0, includeOptionalFields: true)); + PositioningProviderSubscription subscription = + await m_builder.BindGlobalLocationAsync( + state, + provider, + "robot").ConfigureAwait(false); + + InvalidOperationException? failure = null; + try + { + await subscription.Completion.ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + failure = ex; + } + subscription.Dispose(); + + Assert.Multiple(() => + { + Assert.That(failure, Is.Not.Null); + Assert.That(state.Value.Position.Longitude, Is.EqualTo(9.0)); + Assert.That( + state.StatusCode, + Is.EqualTo(StatusCodes.BadCommunicationError)); + Assert.That( + state.Position!.Longitude!.StatusCode, + Is.EqualTo(StatusCodes.BadCommunicationError)); + Assert.That( + state.Orientation!.C!.StatusCode, + Is.EqualTo(StatusCodes.BadCommunicationError)); + }); + } + + [Test] + public async Task ProviderSourceMismatchIsRejectedAndSurfacedAsync() + { + GlobalLocationState state = await CreateGlobalLocationAsync() + .ConfigureAwait(false); + using var provider = new ControlledGlobalProvider( + CreateGlobalSample("other", 8.0, includeOptionalFields: true)); + + ServiceResultException? failure = null; + try + { + _ = await m_builder.BindGlobalLocationAsync( + state, + provider, + "robot").ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + failure = ex; + } + + Assert.Multiple(() => + { + Assert.That(failure, Is.Not.Null); + Assert.That( + failure!.StatusCode, + Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That( + state.StatusCode, + Is.EqualTo(StatusCodes.BadCommunicationError)); + }); + } + + [Test] + public async Task RelativeProviderFailurePreservesFrameAndMarksStatusBadAsync() + { + EUInformation metres = CreateUnit("m", "metre"); + EUInformation degrees = CreateUnit("deg", "degree"); + CartesianFrameAngleOrientationState frame = m_builder.CreateCartesianFrame( + m_owner, + new QualifiedName("TrackedFrame", m_namespaceIndex), + m_owner.NodeId, + CreateFrame(1.0), + metres, + degrees); + m_owner.AddChild(frame); + await m_builder.RegisterAsync(frame).ConfigureAwait(false); + + var provider = new FailingRelativeProvider( + new RelativeSpatialLocationSample( + "robot", + CreateFrame(2.0), + StatusCodes.Good, + DateTimeUtc.Now), + new RelativeSpatialLocationSample( + "robot", + CreateFrame(3.0), + StatusCodes.Good, + DateTimeUtc.Now)); + PositioningProviderSubscription subscription = + await m_builder.BindRelativeSpatialLocationAsync( + frame, + provider, + "robot").ConfigureAwait(false); + + InvalidOperationException? failure = null; + try + { + await subscription.Completion.ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + failure = ex; + } + subscription.Dispose(); + + Assert.Multiple(() => + { + Assert.That(failure, Is.Not.Null); + Assert.That(frame.Value.CartesianCoordinates.X, Is.EqualTo(3.0)); + Assert.That( + frame.StatusCode, + Is.EqualTo(StatusCodes.BadCommunicationError)); + Assert.That( + frame.Position!.StatusCode, + Is.EqualTo(StatusCodes.BadCommunicationError)); + Assert.That( + frame.Position.X!.StatusCode, + Is.EqualTo(StatusCodes.BadCommunicationError)); + Assert.That( + frame.Orientation!.C!.StatusCode, + Is.EqualTo(StatusCodes.BadCommunicationError)); + }); + } + + [Test] + public async Task PollOnlyProviderKeepsAGlobalLocationFreshAsync() + { + GlobalLocationState state = await CreateGlobalLocationAsync() + .ConfigureAwait(false); + // Poll fast so the test does not wait on the one-second default. + state.MinimumSamplingInterval = 20.0; + var provider = new PollOnlyGlobalProvider( + CreateGlobalSample("robot", 8.0, includeOptionalFields: false)); + + PositioningProviderSubscription subscription = + await m_builder.BindGlobalLocationAsync( + state, + provider, + "robot").ConfigureAwait(false); + + Assert.That(state.Position!.Longitude!.Value, Is.EqualTo(8.0)); + + // A provider that cannot push must still be polled, otherwise the + // Variable would serve its initial value with Good status forever. + provider.Publish( + CreateGlobalSample("robot", 9.0, includeOptionalFields: false)); + await WaitUntilAsync( + () => state.Position!.Longitude!.Value == 9.0).ConfigureAwait(false); + + subscription.Dispose(); + + Assert.Multiple(() => + { + Assert.That(provider.WatchCallCount, Is.Zero); + Assert.That(provider.ReadCallCount, Is.GreaterThan(1)); + Assert.That(state.Position!.Longitude!.Value, Is.EqualTo(9.0)); + }); + } + + private async ValueTask CreateGlobalLocationAsync() + { + GlobalLocationState state = m_builder.AttachGlobalLocation( + m_owner, + new QualifiedName("GlobalLocation", m_namespaceIndex), + m_owner.NodeId, + 4326); + await m_builder.RegisterAsync(state).ConfigureAwait(false); + return state; + } + + private static GeoLocationSample CreateGlobalSample( + string sourceId, + double longitude, + bool includeOptionalFields) + { + var position = new GeoPosition( + 47.0, + longitude, + Height: includeOptionalFields ? 500.0 : null, + Accuracy: includeOptionalFields ? 0.1 : null, + Floor: includeOptionalFields ? 2.0f : null, + EpsgCode: 4326); + return new GeoLocationSample( + position, + includeOptionalFields ? new GeoOrientation(0.0, 0.0, 90.0) : null, + default, + StatusCodes.Good, + DateTimeUtc.Now, + sourceId); + } + + private static ThreeDFrame CreateFrame(double x) + { + return new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates { X = x }, + Orientation = new ThreeDOrientation() + }; + } + + private static EUInformation CreateUnit(string unitId, string displayName) + { + return new EUInformation( + unitId, + displayName, + "http://www.opcfoundation.org/UA/units/un/cefact"); + } + + private static async Task WaitUntilAsync(Func condition) + { + for (int i = 0; i < 100; i++) + { + if (condition()) + { + return; + } + await Task.Delay(10).ConfigureAwait(false); + } + Assert.Fail("The provider update was not applied before the timeout."); + } + + /// + /// A provider that cannot push, so the host must poll it. + /// + private sealed class PollOnlyGlobalProvider : IGeoLocationProvider + { + public PollOnlyGlobalProvider(GeoLocationSample initial) + { + m_current = initial; + } + + public bool SupportsPush => false; + + public int ReadCallCount => m_readCallCount; + + public int WatchCallCount => m_watchCallCount; + + public void Publish(GeoLocationSample sample) + { + lock (m_gate) + { + m_current = sample; + } + } + + public ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref m_readCallCount); + lock (m_gate) + { + return new ValueTask(m_current); + } + } + + public async IAsyncEnumerable WatchAsync( + string sourceId, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref m_watchCallCount); + await Task.Yield(); + yield break; + } + + private readonly Lock m_gate = new(); + private GeoLocationSample m_current; + private int m_readCallCount; + private int m_watchCallCount; + } + private sealed class ControlledGlobalProvider : + IGeoLocationProvider, + IDisposable + { + public bool SupportsPush => true; + + private readonly ConcurrentQueue m_samples = new(); + private readonly SemaphoreSlim m_available = new(0); + private readonly GeoLocationSample m_initial; + + private readonly TaskCompletionSource m_cancellationObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ControlledGlobalProvider(GeoLocationSample initial) + { + m_initial = initial; + } + + public Task CancellationObserved => m_cancellationObserved.Task; + + public void Publish(GeoLocationSample sample) + { + m_samples.Enqueue(sample); + m_available.Release(); + } + + public ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken) + { + return new ValueTask(m_initial); + } + + public async IAsyncEnumerable WatchAsync( + string sourceId, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + try + { + while (true) + { + await m_available.WaitAsync(cancellationToken) + .ConfigureAwait(false); + if (m_samples.TryDequeue( + out GeoLocationSample sample)) + { + yield return sample; + } + } + } + finally + { + if (cancellationToken.IsCancellationRequested) + { + m_cancellationObserved.TrySetResult(true); + } + } + } + + public void Dispose() + { + m_available.Dispose(); + } + } + + private sealed class FailingGlobalProvider : IGeoLocationProvider + { + public bool SupportsPush => true; + + private readonly GeoLocationSample m_initial; + private readonly GeoLocationSample m_update; + + public FailingGlobalProvider( + GeoLocationSample initial, + GeoLocationSample update) + { + m_initial = initial; + m_update = update; + } + + public ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken) + { + return new ValueTask(m_initial); + } + + public async IAsyncEnumerable WatchAsync( + string sourceId, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + yield return m_update; + throw new InvalidOperationException("Provider failed."); + } + } + + private sealed class FailingRelativeProvider : + IRelativeSpatialLocationProvider + { + private readonly RelativeSpatialLocationSample m_initial; + private readonly RelativeSpatialLocationSample m_update; + + public FailingRelativeProvider( + RelativeSpatialLocationSample initial, + RelativeSpatialLocationSample update) + { + m_initial = initial; + m_update = update; + } + + public ValueTask ReadAsync( + string sourceId, + CancellationToken cancellationToken) + { + return new ValueTask(m_initial); + } + + public async IAsyncEnumerable + WatchAsync( + string sourceId, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + yield return m_update; + throw new InvalidOperationException("Provider failed."); + } + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/PositioningServerFixture.cs b/tests/Opc.Ua.Positioning.Tests/PositioningServerFixture.cs new file mode 100644 index 0000000000..ccaa553da5 --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/PositioningServerFixture.cs @@ -0,0 +1,73 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Tasks; +using Opc.Ua.Positioning.Server; +using Opc.Ua.Server; +using Opc.Ua.Server.TestFramework; + +namespace Opc.Ua.Positioning.Tests +{ + internal sealed class PositioningServerFixture : IAsyncDisposable + { + private ServerFixture? m_fixture; + + public PositioningNodeManager Manager { get; private set; } = null!; + + public async Task StartAsync() + { + m_fixture = new ServerFixture( + telemetry => new StandardServer(telemetry)) + { + AutoAccept = true, + SecurityNone = true + }; + + StandardServer server = await m_fixture.StartAsync() + .ConfigureAwait(false); + Manager = new PositioningNodeManager( + server.CurrentInstance, + m_fixture.Config); + await Manager.CreateAddressSpaceAsync( + new Dictionary>()) + .ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + Manager?.Dispose(); + if (m_fixture != null) + { + await m_fixture.StopAsync().ConfigureAwait(false); + } + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/RelativeSpatialLocationClientTests.cs b/tests/Opc.Ua.Positioning.Tests/RelativeSpatialLocationClientTests.cs new file mode 100644 index 0000000000..3f2f6a5201 --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/RelativeSpatialLocationClientTests.cs @@ -0,0 +1,457 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Moq; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.Client.Subscriptions; +using Opc.Ua.Client.Subscriptions.Streaming; +using StreamingMonitoredItemOptions = + Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions; + +namespace Opc.Ua.Positioning.Tests +{ + [TestFixture] + [Category("Positioning")] + public sealed class RelativeSpatialLocationClientTests + { + [Test] + public async Task EnumerateFramesWithSpatialAndNonSpatialReferencesReturnsOnlySpatialLocationsAsync() + { + Mock sessionMock = CreateSessionMock(); + NodeId folderId = new("Frames", 2); + NodeId spatialFrameId = new("SpatialFrame", 2); + NodeId otherVariableId = new("OtherVariable", 2); + ReferenceDescription[] references = + [ + CreateReference( + spatialFrameId, + "SpatialFrame", + Rsl.VariableTypeIds.SpatialLocationType), + CreateReference( + otherVariableId, + "OtherVariable", + new ExpandedNodeId(VariableTypeIds.BaseDataVariableType)) + ]; + + SetupBrowseReturns(sessionMock, references); + SetupTypeChecks(sessionMock); + + ITelemetryContext telemetry = Mock.Of(); + var client = new Client.RelativeSpatialLocationClient( + sessionMock.Object, + telemetry); + var entries = new List(); + await foreach (Client.PositioningObjectEntry entry in + client.EnumerateFramesAsync(folderId).ConfigureAwait(false)) + { + entries.Add(entry); + } + + Assert.Multiple(() => + { + Assert.That(client.Telemetry, Is.SameAs(telemetry)); + Assert.That(client.OpenSpatialObject(new NodeId("Robot", 2)), Is.Not.Null); + Assert.That(client.OpenSpatialObjectList(folderId), Is.Not.Null); + Assert.That(entries, Has.Count.EqualTo(1)); + Assert.That(entries[0].NodeId, Is.EqualTo(spatialFrameId)); + Assert.That(entries[0].BrowseName.Name, Is.EqualTo("SpatialFrame")); + }); + } + + [Test] + public async Task ObservePositionFrameWithTypedFrameReturnsBaseNodeAndValueAsync() + { + Mock sessionMock = CreateSessionMock(); + NodeId spatialObjectId = new("SpatialObject", 2); + NodeId frameId = new("PositionFrame", 2); + NodeId basePropertyId = new("BaseProperty", 2); + NodeId worldId = new("World", 2); + ThreeDFrame frame = CreateFrame(1.25); + + SetupTranslateReturns( + sessionMock, + new Dictionary + { + [BuildPathKey( + spatialObjectId, + ReferenceTypeIds.HasComponent, + "PositionFrame")] = frameId, + [BuildPathKey( + frameId, + ReferenceTypeIds.HasComponent, + "Base")] = basePropertyId + }); + SetupReadReturns( + sessionMock, + nodeId => + { + if (nodeId == basePropertyId) + { + return new DataValue(new Variant(worldId), StatusCodes.Good); + } + + throw new AssertionException($"Unexpected read for '{nodeId}'."); + }); + + var client = new Client.RelativeSpatialLocationClient( + sessionMock.Object, + Mock.Of()); + Client.RelativeSpatialFrameValue observed = + await ReadSingleAsync( + client.ObservePositionFrameAsync( + spatialObjectId, + new SingleValueStreamingSubscription( + new DataValue( + Variant.From(new ExtensionObject(frame)), + StatusCodes.Good)))) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(observed.NodeId, Is.EqualTo(frameId)); + Assert.That(observed.BaseNodeId, Is.EqualTo(worldId)); + Assert.That(observed.Frame.CartesianCoordinates.X, Is.EqualTo(1.25)); + Assert.That(observed.Frame.Orientation.A, Is.EqualTo(11.25)); + Assert.That(observed.StatusCode, Is.EqualTo(StatusCodes.Good)); + }); + } + + [Test] + public void ObservePositionFrameWithWrongValueTypeThrowsBadTypeMismatch() + { + Mock sessionMock = CreateSessionMock(); + NodeId spatialObjectId = new("SpatialObject", 2); + NodeId frameId = new("PositionFrame", 2); + NodeId basePropertyId = new("BaseProperty", 2); + NodeId worldId = new("World", 2); + + SetupTranslateReturns( + sessionMock, + new Dictionary + { + [BuildPathKey( + spatialObjectId, + ReferenceTypeIds.HasComponent, + "PositionFrame")] = frameId, + [BuildPathKey( + frameId, + ReferenceTypeIds.HasComponent, + "Base")] = basePropertyId + }); + SetupReadReturns( + sessionMock, + nodeId => + { + if (nodeId == basePropertyId) + { + return new DataValue(new Variant(worldId), StatusCodes.Good); + } + + throw new AssertionException($"Unexpected read for '{nodeId}'."); + }); + + var client = new Client.RelativeSpatialLocationClient( + sessionMock.Object, + Mock.Of()); + + ServiceResultException? exception = Assert.ThrowsAsync( + async () => await ReadSingleAsync( + client.ObservePositionFrameAsync( + spatialObjectId, + new SingleValueStreamingSubscription( + new DataValue(Variant.From("wrong"), StatusCodes.Good)))) + .ConfigureAwait(false)); + + Assert.That(exception!.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch)); + } + + private static Mock CreateSessionMock() + { + var sessionMock = new Mock(); + var context = ServiceMessageContext.Create(null); + context.NamespaceUris.GetIndexOrAppend(Rsl.Namespaces.RSL); + sessionMock.SetupGet(s => s.NamespaceUris).Returns(context.NamespaceUris); + sessionMock.SetupGet(s => s.MessageContext).Returns(context); + sessionMock.SetupGet(s => s.OperationLimits).Returns(new OperationLimits()); + sessionMock.SetupGet(s => s.ServerCapabilities) + .Returns(new ServerCapabilities()); + sessionMock.SetupGet(s => s.ContinuationPointPolicy) + .Returns(ContinuationPointPolicy.Default); + return sessionMock; + } + + private static ReferenceDescription CreateReference( + NodeId nodeId, + string browseName, + ExpandedNodeId typeDefinition) + { + return new ReferenceDescription + { + NodeId = new ExpandedNodeId(nodeId), + BrowseName = new QualifiedName(browseName, nodeId.NamespaceIndex), + DisplayName = new LocalizedText(browseName), + TypeDefinition = typeDefinition, + NodeClass = NodeClass.Variable + }; + } + + private static async Task ReadSingleAsync(IAsyncEnumerable source) + { + await foreach (T value in source.ConfigureAwait(false)) + { + return value; + } + + throw new AssertionException("The async sequence produced no value."); + } + + private static ThreeDFrame CreateFrame(double x) + { + return new ThreeDFrame + { + CartesianCoordinates = new ThreeDCartesianCoordinates + { + X = x, + Y = x + 1.0, + Z = x + 2.0 + }, + Orientation = new ThreeDOrientation + { + A = x + 10.0, + B = x + 20.0, + C = x + 30.0 + } + }; + } + + private static string BuildPathKey( + NodeId parentId, + NodeId referenceTypeId, + string browseName) + { + return $"{parentId}|{referenceTypeId}|{browseName}"; + } + + private static void SetupBrowseReturns( + Mock sessionMock, + ReferenceDescription[] references) + { + sessionMock + .Setup(s => s.BrowseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync( + new BrowseResponse + { + Results = + [ + new BrowseResult + { + References = references + } + ] + }); + } + + private static void SetupTypeChecks(Mock sessionMock) + { + var nodeCacheMock = new Mock(); + nodeCacheMock + .Setup(n => n.IsTypeOfAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + ( + ExpandedNodeId actualTypeId, + ExpandedNodeId expectedTypeId, + CancellationToken _ + ) => new ValueTask(actualTypeId == expectedTypeId)); + sessionMock.SetupGet(s => s.NodeCache).Returns(nodeCacheMock.Object); + } + + private static void SetupTranslateReturns( + Mock sessionMock, + Dictionary targets) + { + sessionMock + .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync( + ( + RequestHeader? _, + ArrayOf browsePaths, + CancellationToken _ + ) => + { + var results = new BrowsePathResult[browsePaths.Count]; + for (int i = 0; i < browsePaths.Count; i++) + { + BrowsePath path = browsePaths[i]; + RelativePathElement element = path.RelativePath.Elements[0]; + if (!targets.TryGetValue( + BuildPathKey( + path.StartingNode, + element.ReferenceTypeId, + element.TargetName.Name ?? string.Empty), + out NodeId targetId)) + { + results[i] = new BrowsePathResult + { + StatusCode = StatusCodes.BadNoMatch + }; + continue; + } + + results[i] = new BrowsePathResult + { + StatusCode = StatusCodes.Good, + Targets = + [ + new BrowsePathTarget + { + TargetId = new ExpandedNodeId(targetId), + RemainingPathIndex = uint.MaxValue + } + ] + }; + } + + return new TranslateBrowsePathsToNodeIdsResponse + { + Results = results + }; + }); + } + + private static void SetupReadReturns( + Mock sessionMock, + Func valueFactory) + { + sessionMock + .Setup(s => s.ReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync( + ( + RequestHeader? _, + double _, + TimestampsToReturn _, + ArrayOf nodesToRead, + CancellationToken _ + ) => + { + var results = new DataValue[nodesToRead.Count]; + for (int i = 0; i < nodesToRead.Count; i++) + { + results[i] = valueFactory(nodesToRead[i].NodeId); + } + + return new ReadResponse + { + Results = results, + DiagnosticInfos = [] + }; + }); + } + + private sealed class SingleValueStreamingSubscription : + IStreamingSubscription + { + private readonly DataValue m_value; + + public SingleValueStreamingSubscription(DataValue value) + { + m_value = value; + } + + public ValueTask DisposeAsync() + { + return default; + } + + public IAsyncEnumerable SubscribeDataChangesAsync( + NodeId nodeId, + StreamingMonitoredItemOptions? options = null, + CancellationToken ct = default) + { + return YieldDataChangeAsync(ct); + } + + public IAsyncEnumerable SubscribeDataChangesAsync( + IReadOnlyList nodeIds, + StreamingMonitoredItemOptions? options = null, + CancellationToken ct = default) + { + return YieldDataChangeAsync(ct); + } + + public IAsyncEnumerable SubscribeEventsAsync( + NodeId notifierId, + EventFilter filter, + StreamingMonitoredItemOptions? options = null, + CancellationToken ct = default) + { + return YieldNoEventsAsync(ct); + } + + private async IAsyncEnumerable YieldDataChangeAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return new DataValueChange(null, m_value, null); + } + + private static async IAsyncEnumerable + YieldNoEventsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield break; + } + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/Transforms/GroundControlPointFitterTests.cs b/tests/Opc.Ua.Positioning.Tests/Transforms/GroundControlPointFitterTests.cs new file mode 100644 index 0000000000..5af13f3a08 --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/Transforms/GroundControlPointFitterTests.cs @@ -0,0 +1,436 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Generic; +using NUnit.Framework; +using Opc.Ua.Gpos; +using static Opc.Ua.Positioning.Tests.Transforms.TransformTestHelpers; + +namespace Opc.Ua.Positioning.Tests.Transforms +{ + /// + /// Tests for covering the rigid, + /// similarity and affine modes with exact, noisy, inverse, reflection and + /// degenerate inputs, as well as the two/three dimensional adaptivity. + /// + [TestFixture] + [Category("Positioning")] + public sealed class GroundControlPointFitterTests + { + private static readonly S3DGeographicCoordinateDataType s_origin = new() + { + EncodingMask = (uint)S3DGeographicCoordinateDataTypeFields.Elevation, + Longitude = 8.0, + Latitude = 47.0, + Elevation = 400.0 + }; + + private static readonly double[][] s_localCloud = + [ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [0.0, 0.0, 10.0], + [5.0, 5.0, 2.0], + [3.0, 4.0, 8.0] + ]; + + private static readonly double[][] s_planarCloud = + [ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [0.0, 10.0, 0.0], + [10.0, 10.0, 0.0], + [5.0, 3.0, 0.0], + [3.0, 7.0, 0.0] + ]; + + private static LocalTangentPlane Plane() + { + return new(s_origin, AngleUnit.Degrees); + } + + private static double[,] Rotation(double a, double b, double c) + { + ArrayOf r = RslFrameTransform + .FromComponents(Orientation(a, b, c), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees) + .RotationMatrix; + return new double[,] + { + { r[0], r[1], r[2] }, + { r[3], r[4], r[5] }, + { r[6], r[7], r[8] } + }; + } + + private static double[] ApplyLinear(double[,] a, double[] t, double[] p, double scale = 1.0) + { + return + [ + (scale * ((a[0, 0] * p[0]) + (a[0, 1] * p[1]) + (a[0, 2] * p[2]))) + t[0], + (scale * ((a[1, 0] * p[0]) + (a[1, 1] * p[1]) + (a[1, 2] * p[2]))) + t[1], + (scale * ((a[2, 0] * p[0]) + (a[2, 1] * p[1]) + (a[2, 2] * p[2]))) + t[2] + ]; + } + + private static double Determinant3(double[,] a) + { + return (a[0, 0] * ((a[1, 1] * a[2, 2]) - (a[1, 2] * a[2, 1]))) - + (a[0, 1] * ((a[1, 0] * a[2, 2]) - (a[1, 2] * a[2, 0]))) + + (a[0, 2] * ((a[1, 0] * a[2, 1]) - (a[1, 1] * a[2, 0]))); + } + + private static GroundControlPointDataType Gcp(double[] local, double[] enu, bool elevation) + { + LocalTangentPlane plane = Plane(); + S3DGeographicCoordinateDataType geo = plane.EnuToGeographic( + Cartesian(enu[0], enu[1], enu[2]), AngleUnit.Degrees, elevation); + return new GroundControlPointDataType + { + LocalPosition = Cartesian(local[0], local[1], local[2]), + GlobalPosition = geo + }; + } + + private static ArrayOf BuildControlPoints( + double[,] linear, double[] translation, double scale, bool elevation) + { + var points = new List(); + foreach (double[] local in s_localCloud) + { + double[] enu = ApplyLinear(linear, translation, local, scale); + points.Add(Gcp(local, enu, elevation)); + } + return ArrayOf.Create(points.ToArray()); + } + + [Test] + public void RigidExactFitRecoversTransform() + { + double[,] rotation = Rotation(30.0, 10.0, 5.0); + double[] translation = [2.0, -3.0, 1.0]; + ArrayOf gcps = + BuildControlPoints(rotation, translation, 1.0, elevation: true); + + var fitter = new GroundControlPointFitter(); + GroundControlPointFitResult result = fitter.Fit(gcps); + + Assert.That(result.Mode, Is.EqualTo(GroundControlPointFitMode.Rigid)); + Assert.That(result.Dimension, Is.EqualTo(3)); + Assert.That(result.Rank, Is.EqualTo(3)); + Assert.That(result.IsInvertible, Is.True); + Assert.That(result.Determinant, Is.EqualTo(1.0).Within(1e-6)); + Assert.That(result.RootMeanSquareError, Is.LessThan(1e-2)); + + AssertRoundTrips(gcps, result, 1e-2); + } + + [Test] + public void SimilarityExactFitRecoversScale() + { + double[,] rotation = Rotation(-20.0, 15.0, 40.0); + double[] translation = [5.0, 6.0, -7.0]; + const double scale = 1.7; + ArrayOf gcps = + BuildControlPoints(rotation, translation, scale, elevation: true); + + var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Similarity }; + GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps, options); + + Assert.That(result.Mode, Is.EqualTo(GroundControlPointFitMode.Similarity)); + Assert.That(result.Dimension, Is.EqualTo(3)); + Assert.That(result.Determinant, Is.EqualTo(scale * scale * scale).Within(1e-2)); + Assert.That(result.RootMeanSquareError, Is.LessThan(1e-2)); + + AssertRoundTrips(gcps, result, 1e-2); + } + + [Test] + public void AffineExactFitRecoversTransform() + { + double[,] linear = + { + { 1.2, 0.1, 0.0 }, + { 0.0, 0.9, 0.05 }, + { 0.02, 0.0, 1.1 } + }; + double[] translation = [1.0, 2.0, 3.0]; + ArrayOf gcps = + BuildControlPoints(linear, translation, 1.0, elevation: true); + + var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Affine }; + GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps, options); + + Assert.That(result.Mode, Is.EqualTo(GroundControlPointFitMode.Affine)); + Assert.That(result.Dimension, Is.EqualTo(3)); + Assert.That(result.Determinant, Is.EqualTo(Determinant3(linear)).Within(1e-3)); + Assert.That(result.RootMeanSquareError, Is.LessThan(1e-2)); + + AssertRoundTrips(gcps, result, 1e-2); + } + + [Test] + public void NoisyRigidFitStaysProperAndBounded() + { + double[,] rotation = Rotation(12.0, -8.0, 25.0); + double[] translation = [1.0, 1.0, 1.0]; + + var points = new List(); + double[] noise = [0.03, -0.04, 0.02, 0.05, -0.03, 0.01]; + for (int i = 0; i < s_localCloud.Length; i++) + { + double[] enu = ApplyLinear(rotation, translation, s_localCloud[i]); + enu[0] += noise[i]; + enu[1] -= noise[i]; + enu[2] += noise[i] * 0.5; + points.Add(Gcp(s_localCloud[i], enu, elevation: true)); + } + var gcps = ArrayOf.Create(points.ToArray()); + + GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps); + + Assert.That(result.Determinant, Is.EqualTo(1.0).Within(1e-6)); + Assert.That(result.RootMeanSquareError, Is.GreaterThan(0.0)); + Assert.That(result.RootMeanSquareError, Is.LessThan(0.2)); + Assert.That(result.MaxResidual, Is.LessThan(0.5)); + } + + [Test] + public void RigidFitOfMirroredInputReturnsProperRotation() + { + // A reflection (det -1) applied in ENU: a rigid fit must return the + // closest proper rotation, not the improper one. + double[,] reflection = + { + { 1.0, 0.0, 0.0 }, + { 0.0, 1.0, 0.0 }, + { 0.0, 0.0, -1.0 } + }; + double[] translation = [0.0, 0.0, 0.0]; + ArrayOf gcps = + BuildControlPoints(reflection, translation, 1.0, elevation: true); + + GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps); + + // Proper rotation regardless of the mirrored data. + Assert.That(result.Determinant, Is.GreaterThan(0.0)); + Assert.That(result.Determinant, Is.EqualTo(1.0).Within(1e-6)); + // The reflection cannot be represented, so the residual is large. + Assert.That(result.RootMeanSquareError, Is.GreaterThan(1.0)); + } + + [Test] + public void AffineRejectsReflectionByDefault() + { + double[,] reflection = + { + { 1.1, 0.0, 0.0 }, + { 0.0, 1.0, 0.0 }, + { 0.0, 0.0, -1.2 } + }; + double[] translation = [0.0, 0.0, 0.0]; + ArrayOf gcps = + BuildControlPoints(reflection, translation, 1.0, elevation: true); + + var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Affine }; + Assert.That(() => new GroundControlPointFitter().Fit(gcps, options), + Throws.ArgumentException); + } + + [Test] + public void AffineAllowsReflectionWhenRequested() + { + double[,] reflection = + { + { 1.1, 0.0, 0.0 }, + { 0.0, 1.0, 0.0 }, + { 0.0, 0.0, -1.2 } + }; + double[] translation = [0.0, 0.0, 0.0]; + ArrayOf gcps = + BuildControlPoints(reflection, translation, 1.0, elevation: true); + + var options = new GroundControlPointFitOptions + { + Mode = GroundControlPointFitMode.Affine, + AllowReflection = true + }; + GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps, options); + + Assert.That(result.Determinant, Is.LessThan(0.0)); + Assert.That(result.Determinant, Is.EqualTo(Determinant3(reflection)).Within(1e-3)); + Assert.That(result.RootMeanSquareError, Is.LessThan(1e-2)); + AssertRoundTrips(gcps, result, 1e-2); + } + + [Test] + public void HorizontalFitWhenElevationMissing() + { + double[,] rotation = Rotation(0.0, 0.0, 35.0); + double[] translation = [4.0, -5.0, 0.0]; + + var points = new List(); + foreach (double[] local in s_planarCloud) + { + double[] planar = [local[0], local[1], 0.0]; + double[] enu = ApplyLinear(rotation, translation, planar); + enu[2] = 0.0; + points.Add(Gcp(planar, enu, elevation: false)); + } + var gcps = ArrayOf.Create(points.ToArray()); + + GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps); + + Assert.That(result.Dimension, Is.EqualTo(2)); + Assert.That(result.Determinant, Is.EqualTo(1.0).Within(1e-6)); + + // The forward mapping produces no elevation, the inverse produces a + // zero Z (no fabricated elevation). + S3DGeographicCoordinateDataType global = + result.LocalToGlobal(Cartesian(2.0, 3.0, 99.0), AngleUnit.Degrees); + Assert.That(GeographicCoordinates.HasElevation(global), Is.False); + + ThreeDCartesianCoordinates back = result.GlobalToLocal(global, AngleUnit.Degrees); + Assert.That(back.Z, Is.Zero.Within(1e-9)); + } + + [Test] + public void AffineWithCoplanarThreeDPointsFallsBackToHorizontal() + { + // Points carry elevation but are coplanar in the local frame (z=0), + // so a full 3D affine fit is not possible and the fit drops to 2D. + double[,] linear = + { + { 1.1, 0.05, 0.0 }, + { 0.0, 0.95, 0.0 }, + { 0.0, 0.0, 1.0 } + }; + double[] translation = [1.0, 1.0, 5.0]; + + var points = new List(); + foreach (double[] local in s_planarCloud) + { + double[] planar = [local[0], local[1], 0.0]; + double[] enu = ApplyLinear(linear, translation, planar); + points.Add(Gcp(planar, enu, elevation: true)); + } + var gcps = ArrayOf.Create(points.ToArray()); + + var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Affine }; + GroundControlPointFitResult result = new GroundControlPointFitter().Fit(gcps, options); + + Assert.That(result.Dimension, Is.EqualTo(2)); + } + + [Test] + public void RejectsNullControlPoints() + { + Assert.That(() => new GroundControlPointFitter().Fit(ArrayOf.Null), + Throws.ArgumentException); + } + + [Test] + public void RejectsInsufficientControlPoints() + { + var single = new GroundControlPointDataType + { + LocalPosition = Cartesian(0.0, 0.0, 0.0), + GlobalPosition = s_origin + }; + var gcps = ArrayOf.Create([single]); + + Assert.That(() => new GroundControlPointFitter().Fit(gcps), Throws.ArgumentException); + } + + [Test] + public void RejectsDuplicateLocalPositions() + { + GroundControlPointDataType a = Gcp([1.0, 2.0, 3.0], [1.0, 2.0, 3.0], elevation: true); + GroundControlPointDataType b = Gcp([1.0, 2.0, 3.0], [4.0, 5.0, 6.0], elevation: true); + GroundControlPointDataType c = Gcp([9.0, 8.0, 7.0], [7.0, 8.0, 9.0], elevation: true); + var gcps = ArrayOf.Create([a, b, c]); + + Assert.That(() => new GroundControlPointFitter().Fit(gcps), Throws.ArgumentException); + } + + [Test] + public void RejectsNonFiniteLocalPosition() + { + var a = new GroundControlPointDataType + { + LocalPosition = Cartesian(double.NaN, 0.0, 0.0), + GlobalPosition = s_origin + }; + GroundControlPointDataType b = Gcp([1.0, 0.0, 0.0], [1.0, 0.0, 0.0], elevation: true); + GroundControlPointDataType c = Gcp([0.0, 1.0, 0.0], [0.0, 1.0, 0.0], elevation: true); + var gcps = ArrayOf.Create([a, b, c]); + + Assert.That(() => new GroundControlPointFitter().Fit(gcps), Throws.ArgumentException); + } + + [Test] + public void RejectsRankDeficientAffineInput() + { + // Collinear points (rank one) cannot support an affine fit. + var points = new List(); + for (int i = 0; i < 4; i++) + { + double[] local = [i, 0.0, 0.0]; + double[] enu = [2.0 * i, 0.0, 0.0]; + points.Add(Gcp(local, enu, elevation: false)); + } + var gcps = ArrayOf.Create(points.ToArray()); + + var options = new GroundControlPointFitOptions { Mode = GroundControlPointFitMode.Affine }; + Assert.That(() => new GroundControlPointFitter().Fit(gcps, options), Throws.ArgumentException); + } + + private static void AssertRoundTrips( + ArrayOf gcps, GroundControlPointFitResult result, double toleranceMeters) + { + for (int i = 0; i < gcps.Count; i++) + { + GroundControlPointDataType gcp = gcps[i]; + S3DGeographicCoordinateDataType predicted = + result.LocalToGlobal(gcp.LocalPosition, AngleUnit.Degrees); + Assert.That( + GeographicDistanceMeters(predicted, gcp.GlobalPosition), + Is.LessThan(toleranceMeters), + $"forward mapping mismatch at index {i}"); + + ThreeDCartesianCoordinates back = + result.GlobalToLocal(gcp.GlobalPosition, AngleUnit.Degrees); + Assert.That( + Distance(back, gcp.LocalPosition), + Is.LessThan(toleranceMeters), + $"inverse mapping mismatch at index {i}"); + } + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/Transforms/InternalMathTests.cs b/tests/Opc.Ua.Positioning.Tests/Transforms/InternalMathTests.cs new file mode 100644 index 0000000000..996c4948d6 --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/Transforms/InternalMathTests.cs @@ -0,0 +1,123 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; + +namespace Opc.Ua.Positioning.Tests.Transforms +{ + [TestFixture] + [Category("Positioning")] + public sealed class InternalMathTests + { + [Test] + public void MatrixEqualityChecksEveryElement() + { + var expected = new Matrix3x3( + 1.0, 2.0, 3.0, + 4.0, 5.0, 6.0, + 7.0, 8.0, 9.0); + Matrix3x3[] different = + [ + new(0.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0), + new(1.0, 0.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0), + new(1.0, 2.0, 0.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0), + new(1.0, 2.0, 3.0, 0.0, 5.0, 6.0, 7.0, 8.0, 9.0), + new(1.0, 2.0, 3.0, 4.0, 0.0, 6.0, 7.0, 8.0, 9.0), + new(1.0, 2.0, 3.0, 4.0, 5.0, 0.0, 7.0, 8.0, 9.0), + new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 0.0, 8.0, 9.0), + new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 0.0, 9.0), + new(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 0.0) + ]; + + Assert.Multiple(() => + { + var same = new Matrix3x3( + 1.0, 2.0, 3.0, + 4.0, 5.0, 6.0, + 7.0, 8.0, 9.0); + Assert.That( + expected, + Is.EqualTo(same)); + Assert.That((object)expected, Is.EqualTo(same)); + Assert.That((object)expected, Is.Not.EqualTo("not a matrix")); + Assert.That( + expected.GetHashCode(), + Is.EqualTo(same.GetHashCode())); + for (int i = 0; i < different.Length; i++) + { + Assert.That(expected, Is.Not.EqualTo(different[i])); + } + }); + } + + [Test] + public void VectorOperationsAndIndexValidationAreDeterministic() + { + var left = new Vector3(1.0, 2.0, 3.0); + var right = new Vector3(4.0, 5.0, 6.0); + + Assert.Multiple(() => + { + Assert.That(left.LengthSquared, Is.EqualTo(14.0)); + Assert.That(left.Length, Is.EqualTo(Math.Sqrt(14.0))); + Assert.That(left.IsFinite, Is.True); + Assert.That( + left + right, + Is.EqualTo(new Vector3(5.0, 7.0, 9.0))); + Assert.That( + right - left, + Is.EqualTo(new Vector3(3.0, 3.0, 3.0))); + Assert.That( + left * 2.0, + Is.EqualTo(new Vector3(2.0, 4.0, 6.0))); + Assert.That( + 2.0 * left, + Is.EqualTo(new Vector3(2.0, 4.0, 6.0))); + Assert.That(Vector3.Dot(left, right), Is.EqualTo(32.0)); + Assert.That( + Vector3.Cross(left, right), + Is.EqualTo(new Vector3(-3.0, 6.0, -3.0))); + Assert.That(left[0], Is.EqualTo(1.0)); + Assert.That(left[1], Is.EqualTo(2.0)); + Assert.That(left[2], Is.EqualTo(3.0)); + Assert.That( + () => _ = left[3], + Throws.TypeOf()); + Assert.That((object)left, Is.EqualTo(left)); + Assert.That((object)left, Is.Not.EqualTo("not a vector")); + Assert.That( + new Vector3(double.NaN, 0.0, 0.0).IsFinite, + Is.False); + Assert.That(left.GetHashCode(), Is.EqualTo( + new Vector3(1.0, 2.0, 3.0).GetHashCode())); + }); + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/Transforms/RslFrameTransformTests.cs b/tests/Opc.Ua.Positioning.Tests/Transforms/RslFrameTransformTests.cs new file mode 100644 index 0000000000..08ad3d20fe --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/Transforms/RslFrameTransformTests.cs @@ -0,0 +1,286 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using static Opc.Ua.Positioning.Tests.Transforms.TransformTestHelpers; + +namespace Opc.Ua.Positioning.Tests.Transforms +{ + /// + /// Tests for against the OPC 10000-210 Annex B + /// frame conventions: right handed axes, roll A about X, pitch B about Y, yaw + /// C about Z, intrinsic z-y'-x'' equivalent to Rz(C)*Ry(B)*Rx(A). + /// + [TestFixture] + [Category("Positioning")] + public sealed class RslFrameTransformTests + { + private const double kTolerance = 1e-12; + + [Test] + public void YawNinetyDegreesRotatesXAxisToYAxis() + { + var t = RslFrameTransform.FromComponents( + Orientation(0.0, 0.0, 90.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + + ThreeDCartesianCoordinates p = t.TransformPoint(Cartesian(1.0, 0.0, 0.0)); + + Assert.That(p.X, Is.Zero.Within(kTolerance)); + Assert.That(p.Y, Is.EqualTo(1.0).Within(kTolerance)); + Assert.That(p.Z, Is.Zero.Within(kTolerance)); + } + + [Test] + public void RollNinetyDegreesRotatesYAxisToZAxis() + { + var t = RslFrameTransform.FromComponents( + Orientation(90.0, 0.0, 0.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + + ThreeDCartesianCoordinates p = t.TransformPoint(Cartesian(0.0, 1.0, 0.0)); + + Assert.That(p.X, Is.Zero.Within(kTolerance)); + Assert.That(p.Y, Is.Zero.Within(kTolerance)); + Assert.That(p.Z, Is.EqualTo(1.0).Within(kTolerance)); + } + + [Test] + public void PitchNinetyDegreesRotatesZAxisToXAxis() + { + var t = RslFrameTransform.FromComponents( + Orientation(0.0, 90.0, 0.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + + ThreeDCartesianCoordinates p = t.TransformPoint(Cartesian(0.0, 0.0, 1.0)); + + Assert.That(p.X, Is.EqualTo(1.0).Within(kTolerance)); + Assert.That(p.Y, Is.Zero.Within(kTolerance)); + Assert.That(p.Z, Is.Zero.Within(kTolerance)); + } + + [Test] + public void CompositionDoesNotCommuteGoldenVectors() + { + var roll = RslFrameTransform.FromComponents( + Orientation(90.0, 0.0, 0.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + var yaw = RslFrameTransform.FromComponents( + Orientation(0.0, 0.0, 90.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + + // Apply yaw first, then roll. + ThreeDCartesianCoordinates yawThenRoll = roll.Compose(yaw).TransformPoint(Cartesian(1.0, 0.0, 0.0)); + // Apply roll first, then yaw. + ThreeDCartesianCoordinates rollThenYaw = yaw.Compose(roll).TransformPoint(Cartesian(1.0, 0.0, 0.0)); + + // Golden absolute results, not a mere round trip. + Assert.That(yawThenRoll.X, Is.Zero.Within(kTolerance)); + Assert.That(yawThenRoll.Y, Is.Zero.Within(kTolerance)); + Assert.That(yawThenRoll.Z, Is.EqualTo(1.0).Within(kTolerance)); + + Assert.That(rollThenYaw.X, Is.Zero.Within(kTolerance)); + Assert.That(rollThenYaw.Y, Is.EqualTo(1.0).Within(kTolerance)); + Assert.That(rollThenYaw.Z, Is.Zero.Within(kTolerance)); + + Assert.That(Distance(yawThenRoll, rollThenYaw), Is.GreaterThan(1.0)); + } + + [Test] + public void IntrinsicOrderMatchesRzRyRxGoldenVector() + { + // A=90 (roll), C=90 (yaw), B=0 => R = Rz(90) * Rx(90). + var t = RslFrameTransform.FromComponents( + Orientation(90.0, 0.0, 90.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + + ThreeDCartesianCoordinates onX = t.TransformPoint(Cartesian(1.0, 0.0, 0.0)); + ThreeDCartesianCoordinates onY = t.TransformPoint(Cartesian(0.0, 1.0, 0.0)); + ThreeDCartesianCoordinates onZ = t.TransformPoint(Cartesian(0.0, 0.0, 1.0)); + + // e_x -> e_y + Assert.That(onX.X, Is.Zero.Within(kTolerance)); + Assert.That(onX.Y, Is.EqualTo(1.0).Within(kTolerance)); + Assert.That(onX.Z, Is.Zero.Within(kTolerance)); + + // e_y -> e_z (Rx sends e_y to e_z, Rz leaves e_z) + Assert.That(onY.X, Is.Zero.Within(kTolerance)); + Assert.That(onY.Y, Is.Zero.Within(kTolerance)); + Assert.That(onY.Z, Is.EqualTo(1.0).Within(kTolerance)); + + // e_z -> Rx sends e_z to -e_y, Rz sends -e_y to e_x + Assert.That(onZ.X, Is.EqualTo(1.0).Within(kTolerance)); + Assert.That(onZ.Y, Is.Zero.Within(kTolerance)); + Assert.That(onZ.Z, Is.Zero.Within(kTolerance)); + } + + [Test] + public void TransformPointAppliesRotationThenTranslation() + { + var t = RslFrameTransform.FromComponents( + Orientation(0.0, 0.0, 90.0), Cartesian(5.0, -2.0, 3.0), AngleUnit.Degrees); + + ThreeDCartesianCoordinates p = t.TransformPoint(Cartesian(1.0, 0.0, 0.0)); + + Assert.That(p.X, Is.EqualTo(5.0).Within(kTolerance)); + Assert.That(p.Y, Is.EqualTo(-1.0).Within(kTolerance)); + Assert.That(p.Z, Is.EqualTo(3.0).Within(kTolerance)); + } + + [Test] + public void RoundTripRecoversAnglesAndTranslation() + { + ThreeDFrame frame = new() + { + CartesianCoordinates = Cartesian(1.5, -2.5, 3.5), + Orientation = Orientation(10.0, 20.0, 30.0) + }; + + var t = RslFrameTransform.FromFrame(frame, AngleUnit.Degrees); + ThreeDFrame recovered = t.ToFrame(AngleUnit.Degrees); + + Assert.That(recovered.Orientation.A, Is.EqualTo(10.0).Within(1e-9)); + Assert.That(recovered.Orientation.B, Is.EqualTo(20.0).Within(1e-9)); + Assert.That(recovered.Orientation.C, Is.EqualTo(30.0).Within(1e-9)); + Assert.That(recovered.CartesianCoordinates.X, Is.EqualTo(1.5).Within(kTolerance)); + Assert.That(recovered.CartesianCoordinates.Y, Is.EqualTo(-2.5).Within(kTolerance)); + Assert.That(recovered.CartesianCoordinates.Z, Is.EqualTo(3.5).Within(kTolerance)); + } + + [Test] + public void RadiansAndDegreesAreConsistent() + { + var degrees = RslFrameTransform.FromComponents( + Orientation(30.0, 45.0, 60.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + var radians = RslFrameTransform.FromComponents( + Orientation(System.Math.PI / 6.0, System.Math.PI / 4.0, System.Math.PI / 3.0), + Cartesian(0.0, 0.0, 0.0), AngleUnit.Radians); + + ThreeDCartesianCoordinates a = degrees.TransformPoint(Cartesian(1.0, 2.0, 3.0)); + ThreeDCartesianCoordinates b = radians.TransformPoint(Cartesian(1.0, 2.0, 3.0)); + + Assert.That(Distance(a, b), Is.LessThan(1e-12)); + } + + [Test] + public void InverseUndoesTransform() + { + var t = RslFrameTransform.FromComponents( + Orientation(15.0, -35.0, 80.0), Cartesian(7.0, 8.0, -9.0), AngleUnit.Degrees); + + ThreeDCartesianCoordinates original = Cartesian(3.0, -4.0, 5.0); + ThreeDCartesianCoordinates mapped = t.TransformPoint(original); + ThreeDCartesianCoordinates back = t.Inverse().TransformPoint(mapped); + + Assert.That(Distance(back, original), Is.LessThan(1e-10)); + } + + [Test] + public void InverseComposedWithTransformIsIdentity() + { + var t = RslFrameTransform.FromComponents( + Orientation(22.0, 33.0, 44.0), Cartesian(1.0, 2.0, 3.0), AngleUnit.Degrees); + + RslFrameTransform identity = t.Inverse().Compose(t); + ThreeDCartesianCoordinates p = identity.TransformPoint(Cartesian(9.0, -8.0, 7.0)); + + Assert.That(p.X, Is.EqualTo(9.0).Within(1e-10)); + Assert.That(p.Y, Is.EqualTo(-8.0).Within(1e-10)); + Assert.That(p.Z, Is.EqualTo(7.0).Within(1e-10)); + } + + [Test] + public void GimbalLockResolvesRollToZeroDeterministically() + { + var original = RslFrameTransform.FromComponents( + Orientation(30.0, 90.0, 40.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + + ThreeDFrame decomposed = original.ToFrame(AngleUnit.Degrees); + + // At the pitch singularity the roll is deterministically folded to + // zero and the pitch is exactly +90 degrees. + Assert.That(decomposed.Orientation.A, Is.Zero.Within(1e-9)); + Assert.That(decomposed.Orientation.B, Is.EqualTo(90.0).Within(1e-9)); + + // Even though the recovered angles differ, they rebuild the same + // rotation (same action on every basis vector). + var rebuilt = RslFrameTransform.FromFrame(decomposed, AngleUnit.Degrees); + foreach (ThreeDCartesianCoordinates basis in new[] + { + Cartesian(1.0, 0.0, 0.0), + Cartesian(0.0, 1.0, 0.0), + Cartesian(0.0, 0.0, 1.0) + }) + { + Assert.That( + Distance(original.TransformPoint(basis), rebuilt.TransformPoint(basis)), + Is.LessThan(1e-10)); + } + } + + [Test] + public void NegativePitchGimbalLockIsHandled() + { + var original = RslFrameTransform.FromComponents( + Orientation(25.0, -90.0, 65.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + + ThreeDFrame decomposed = original.ToFrame(AngleUnit.Degrees); + Assert.That(decomposed.Orientation.A, Is.Zero.Within(1e-9)); + Assert.That(decomposed.Orientation.B, Is.EqualTo(-90.0).Within(1e-9)); + + var rebuilt = RslFrameTransform.FromFrame(decomposed, AngleUnit.Degrees); + Assert.That( + Distance( + original.TransformPoint(Cartesian(1.0, 2.0, 3.0)), + rebuilt.TransformPoint(Cartesian(1.0, 2.0, 3.0))), + Is.LessThan(1e-10)); + } + + [Test] + public void IdentityTransformLeavesPointUnchanged() + { + ThreeDCartesianCoordinates p = RslFrameTransform.Identity.TransformPoint(Cartesian(4.0, 5.0, 6.0)); + Assert.That(p.X, Is.EqualTo(4.0).Within(kTolerance)); + Assert.That(p.Y, Is.EqualTo(5.0).Within(kTolerance)); + Assert.That(p.Z, Is.EqualTo(6.0).Within(kTolerance)); + } + + [Test] + public void RotationMatrixIsProperOrthonormal() + { + var t = RslFrameTransform.FromComponents( + Orientation(12.0, -34.0, 56.0), Cartesian(0.0, 0.0, 0.0), AngleUnit.Degrees); + + ArrayOf r = t.RotationMatrix; + Assert.That(r.Count, Is.EqualTo(9)); + + // Column norms are one (orthonormal rotation). + double col0 = (r[0] * r[0]) + (r[3] * r[3]) + (r[6] * r[6]); + double col1 = (r[1] * r[1]) + (r[4] * r[4]) + (r[7] * r[7]); + double col2 = (r[2] * r[2]) + (r[5] * r[5]) + (r[8] * r[8]); + Assert.That(col0, Is.EqualTo(1.0).Within(1e-12)); + Assert.That(col1, Is.EqualTo(1.0).Within(1e-12)); + Assert.That(col2, Is.EqualTo(1.0).Within(1e-12)); + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/Transforms/TransformTestHelpers.cs b/tests/Opc.Ua.Positioning.Tests/Transforms/TransformTestHelpers.cs new file mode 100644 index 0000000000..cdad54a2bf --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/Transforms/TransformTestHelpers.cs @@ -0,0 +1,68 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Opc.Ua.Gpos; + +namespace Opc.Ua.Positioning.Tests.Transforms +{ + /// + /// Shared helpers for the positioning transform tests. + /// + internal static class TransformTestHelpers + { + public static ThreeDCartesianCoordinates Cartesian(double x, double y, double z) + { + return new() { X = x, Y = y, Z = z }; + } + + public static ThreeDOrientation Orientation(double a, double b, double c) + { + return new() { A = a, B = b, C = c }; + } + + public static double Distance(ThreeDCartesianCoordinates a, ThreeDCartesianCoordinates b) + { + double dx = a.X - b.X; + double dy = a.Y - b.Y; + double dz = a.Z - b.Z; + return Math.Sqrt((dx * dx) + (dy * dy) + (dz * dz)); + } + + public static double GeographicDistanceMeters( + S3DGeographicCoordinateDataType a, S3DGeographicCoordinateDataType b) + { + Wgs84CoordinateReferenceSystemTransformer crs = + Wgs84CoordinateReferenceSystemTransformer.Instance; + ThreeDCartesianCoordinates ea = crs.ToEarthCenteredEarthFixed(a, AngleUnit.Degrees); + ThreeDCartesianCoordinates eb = crs.ToEarthCenteredEarthFixed(b, AngleUnit.Degrees); + return Distance(ea, eb); + } + } +} diff --git a/tests/Opc.Ua.Positioning.Tests/Transforms/Wgs84TransformTests.cs b/tests/Opc.Ua.Positioning.Tests/Transforms/Wgs84TransformTests.cs new file mode 100644 index 0000000000..122e30718f --- /dev/null +++ b/tests/Opc.Ua.Positioning.Tests/Transforms/Wgs84TransformTests.cs @@ -0,0 +1,196 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.Gpos; +using static Opc.Ua.Positioning.Tests.Transforms.TransformTestHelpers; + +namespace Opc.Ua.Positioning.Tests.Transforms +{ + /// + /// Tests for the WGS84 (EPSG:4326) geodetic to ECEF conversion and the local + /// ENU tangent plane. + /// + [TestFixture] + [Category("Positioning")] + public sealed class Wgs84TransformTests + { + private const double kSemiMajorAxis = 6378137.0; + private const double kSemiMinorAxis = 6356752.314245179; + + private static S3DGeographicCoordinateDataType Geo(double lon, double lat, double elevation) + { + return new() + { + EncodingMask = (uint)S3DGeographicCoordinateDataTypeFields.Elevation, + Longitude = lon, + Latitude = lat, + Elevation = elevation + }; + } + + [Test] + public void EquatorPrimeMeridianMapsToSemiMajorAxisOnX() + { + Wgs84CoordinateReferenceSystemTransformer crs = + Wgs84CoordinateReferenceSystemTransformer.Instance; + + ThreeDCartesianCoordinates ecef = + crs.ToEarthCenteredEarthFixed(Geo(0.0, 0.0, 0.0), AngleUnit.Degrees); + + Assert.That(ecef.X, Is.EqualTo(kSemiMajorAxis).Within(1e-3)); + Assert.That(ecef.Y, Is.Zero.Within(1e-6)); + Assert.That(ecef.Z, Is.Zero.Within(1e-6)); + } + + [Test] + public void NorthPoleMapsToSemiMinorAxisOnZ() + { + Wgs84CoordinateReferenceSystemTransformer crs = + Wgs84CoordinateReferenceSystemTransformer.Instance; + + ThreeDCartesianCoordinates ecef = + crs.ToEarthCenteredEarthFixed(Geo(0.0, 90.0, 0.0), AngleUnit.Degrees); + + Assert.That(ecef.Z, Is.EqualTo(kSemiMinorAxis).Within(1e-3)); + Assert.That(ecef.X, Is.Zero.Within(1e-6)); + Assert.That(ecef.Y, Is.Zero.Within(1e-6)); + } + + [Test] + public void NinetyEastMapsToSemiMajorAxisOnY() + { + Wgs84CoordinateReferenceSystemTransformer crs = + Wgs84CoordinateReferenceSystemTransformer.Instance; + + ThreeDCartesianCoordinates ecef = + crs.ToEarthCenteredEarthFixed(Geo(90.0, 0.0, 0.0), AngleUnit.Degrees); + + Assert.That(ecef.Y, Is.EqualTo(kSemiMajorAxis).Within(1e-3)); + Assert.That(ecef.X, Is.Zero.Within(1e-6)); + Assert.That(ecef.Z, Is.Zero.Within(1e-6)); + } + + [Test] + public void GeodeticEcefRoundTrip() + { + Wgs84CoordinateReferenceSystemTransformer crs = + Wgs84CoordinateReferenceSystemTransformer.Instance; + + S3DGeographicCoordinateDataType input = Geo(11.6, 48.1, 519.0); + ThreeDCartesianCoordinates ecef = crs.ToEarthCenteredEarthFixed(input, AngleUnit.Degrees); + S3DGeographicCoordinateDataType output = + crs.FromEarthCenteredEarthFixed(ecef, AngleUnit.Degrees, includeElevation: true); + + Assert.That(output.Longitude, Is.EqualTo(11.6).Within(1e-9)); + Assert.That(output.Latitude, Is.EqualTo(48.1).Within(1e-9)); + Assert.That(output.Elevation, Is.EqualTo(519.0).Within(1e-4)); + Assert.That(GeographicCoordinates.HasElevation(output), Is.True); + } + + [Test] + public void FromEcefWithoutElevationDoesNotFabricateElevation() + { + Wgs84CoordinateReferenceSystemTransformer crs = + Wgs84CoordinateReferenceSystemTransformer.Instance; + + ThreeDCartesianCoordinates ecef = + crs.ToEarthCenteredEarthFixed(Geo(8.0, 47.0, 400.0), AngleUnit.Degrees); + S3DGeographicCoordinateDataType output = + crs.FromEarthCenteredEarthFixed(ecef, AngleUnit.Degrees, includeElevation: false); + + Assert.That(GeographicCoordinates.HasElevation(output), Is.False); + Assert.That(output.Longitude, Is.EqualTo(8.0).Within(1e-9)); + Assert.That(output.Latitude, Is.EqualTo(47.0).Within(1e-9)); + } + + [Test] + public void CoordinateReferenceSystemIsEpsg4326() + { + Assert.That( + Wgs84CoordinateReferenceSystemTransformer.Instance.CoordinateReferenceSystem, + Is.EqualTo("EPSG:4326")); + } + + [Test] + public void TangentPlaneOriginMapsToEnuZero() + { + var plane = new LocalTangentPlane(Geo(8.0, 47.0, 400.0), AngleUnit.Degrees); + ThreeDCartesianCoordinates enu = plane.GeographicToEnu(Geo(8.0, 47.0, 400.0), AngleUnit.Degrees); + + Assert.That(enu.X, Is.Zero.Within(1e-6)); + Assert.That(enu.Y, Is.Zero.Within(1e-6)); + Assert.That(enu.Z, Is.Zero.Within(1e-6)); + } + + [Test] + public void TangentPlaneAxesAtEquatorPrimeMeridian() + { + // At lat=0, lon=0 the ECEF origin is (a,0,0). East = +Y, North = +Z, + // Up = +X. + var plane = new LocalTangentPlane(Geo(0.0, 0.0, 0.0), AngleUnit.Degrees); + + ThreeDCartesianCoordinates east = + plane.EarthCenteredEarthFixedToEnu(Cartesian(kSemiMajorAxis, 10.0, 0.0)); + ThreeDCartesianCoordinates north = + plane.EarthCenteredEarthFixedToEnu(Cartesian(kSemiMajorAxis, 0.0, 10.0)); + ThreeDCartesianCoordinates up = + plane.EarthCenteredEarthFixedToEnu(Cartesian(kSemiMajorAxis + 10.0, 0.0, 0.0)); + + Assert.That(east.X, Is.EqualTo(10.0).Within(1e-6)); + Assert.That(north.Y, Is.EqualTo(10.0).Within(1e-6)); + Assert.That(up.Z, Is.EqualTo(10.0).Within(1e-6)); + } + + [Test] + public void TangentPlaneEnuEcefRoundTrip() + { + var plane = new LocalTangentPlane(Geo(8.0, 47.0, 400.0), AngleUnit.Degrees); + ThreeDCartesianCoordinates enu = Cartesian(123.0, -456.0, 78.0); + + ThreeDCartesianCoordinates ecef = plane.EnuToEarthCenteredEarthFixed(enu); + ThreeDCartesianCoordinates back = plane.EarthCenteredEarthFixedToEnu(ecef); + + Assert.That(Distance(back, enu), Is.LessThan(1e-6)); + } + + [Test] + public void TangentPlaneGeographicRoundTrip() + { + var plane = new LocalTangentPlane(Geo(8.0, 47.0, 400.0), AngleUnit.Degrees); + S3DGeographicCoordinateDataType point = Geo(8.001, 47.001, 410.0); + + ThreeDCartesianCoordinates enu = plane.GeographicToEnu(point, AngleUnit.Degrees); + S3DGeographicCoordinateDataType back = + plane.EnuToGeographic(enu, AngleUnit.Degrees, includeElevation: true); + + Assert.That(GeographicDistanceMeters(point, back), Is.LessThan(1e-4)); + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/GeneratedMethodSignatureTests.cs b/tests/Opc.Ua.Robotics.Tests/GeneratedMethodSignatureTests.cs new file mode 100644 index 0000000000..f9a04f81f2 --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/GeneratedMethodSignatureTests.cs @@ -0,0 +1,194 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Reflection; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Robotics; + +namespace Opc.Ua.Robotics.Client.Tests +{ + /// + /// Verifies the public method-state APIs generated from the Robotics NodeSet. + /// + [TestFixture] + [Category("Robotics")] + public sealed class GeneratedMethodSignatureTests + { + [Test] + public void LoadByNameCarriesNameAndStatus() + { + MethodInfo syncInvoke = GetInvoke("LoadByNameMethodStateMethodCallHandler"); + MethodInfo asyncInvoke = GetInvoke("LoadByNameMethodStateMethodAsyncCallHandler"); + Type resultType = GetRoboticsType("LoadByNameMethodStateResult"); + + Assert.Multiple(() => + { + Assert.That(syncInvoke.GetParameters(), Has.Length.EqualTo(5)); + Assert.That(syncInvoke.GetParameters()[3].ParameterType, Is.EqualTo(typeof(string))); + Assert.That(syncInvoke.GetParameters()[3].Name, Is.EqualTo("name")); + Assert.That( + syncInvoke.GetParameters()[4].ParameterType, + Is.EqualTo(typeof(int).MakeByRefType())); + Assert.That(asyncInvoke.GetParameters(), Has.Length.EqualTo(5)); + Assert.That(asyncInvoke.GetParameters()[3].ParameterType, Is.EqualTo(typeof(string))); + Assert.That( + asyncInvoke.GetParameters()[4].ParameterType, + Is.EqualTo(typeof(CancellationToken))); + AssertAsyncResult(asyncInvoke.ReturnType, resultType); + Assert.That(resultType.GetProperty("ServiceResult"), Is.Not.Null); + Assert.That(resultType.GetProperty("Status")?.PropertyType, Is.EqualTo(typeof(int))); + }); + } + + [Test] + public void StopCarriesModeAndStatus() + { + MethodInfo syncInvoke = GetInvoke("StopMethodStateMethodCallHandler"); + MethodInfo asyncInvoke = GetInvoke("StopMethodStateMethodAsyncCallHandler"); + Type resultType = GetRoboticsType("StopMethodStateResult"); + + Assert.Multiple(() => + { + Assert.That(syncInvoke.GetParameters(), Has.Length.EqualTo(5)); + Assert.That(syncInvoke.GetParameters()[3].ParameterType, Is.EqualTo(typeof(long))); + Assert.That(syncInvoke.GetParameters()[3].Name, Is.EqualTo("stopMode")); + Assert.That( + syncInvoke.GetParameters()[4].ParameterType, + Is.EqualTo(typeof(int).MakeByRefType())); + Assert.That(asyncInvoke.GetParameters(), Has.Length.EqualTo(5)); + Assert.That(asyncInvoke.GetParameters()[3].ParameterType, Is.EqualTo(typeof(long))); + Assert.That( + asyncInvoke.GetParameters()[4].ParameterType, + Is.EqualTo(typeof(CancellationToken))); + AssertAsyncResult(asyncInvoke.ReturnType, resultType); + Assert.That(resultType.GetProperty("Status")?.PropertyType, Is.EqualTo(typeof(int))); + }); + } + + [Test] + public void GetReadyCarriesStatus() + { + MethodInfo syncInvoke = GetInvoke("GetReadyMethodStateMethodCallHandler"); + MethodInfo asyncInvoke = GetInvoke("GetReadyMethodStateMethodAsyncCallHandler"); + Type resultType = GetRoboticsType("GetReadyMethodStateResult"); + + Assert.Multiple(() => + { + Assert.That(syncInvoke.GetParameters(), Has.Length.EqualTo(4)); + Assert.That( + syncInvoke.GetParameters()[3].ParameterType, + Is.EqualTo(typeof(int).MakeByRefType())); + Assert.That(asyncInvoke.GetParameters(), Has.Length.EqualTo(4)); + Assert.That( + asyncInvoke.GetParameters()[3].ParameterType, + Is.EqualTo(typeof(CancellationToken))); + AssertAsyncResult(asyncInvoke.ReturnType, resultType); + Assert.That(resultType.GetProperty("Status")?.PropertyType, Is.EqualTo(typeof(int))); + }); + } + + [Test] + public void DiGetUpdateBehaviorOverloadsRemainDistinct() + { + MethodInfo cachedInvoke = GetDiInvoke( + "GetUpdateBehaviorCachedLoadingMethodStateMethodCallHandler"); + MethodInfo fileSystemInvoke = GetDiInvoke( + "GetUpdateBehaviorFileSystemMethodStateMethodCallHandler"); + Type cachedState = GetDiType("CachedLoadingState"); + Type fileSystemState = GetDiType("FileSystemLoadingState"); + + ParameterInfo[] cachedParameters = cachedInvoke.GetParameters(); + ParameterInfo[] fileSystemParameters = fileSystemInvoke.GetParameters(); + Assert.Multiple(() => + { + Assert.That(cachedParameters, Has.Length.EqualTo(7)); + Assert.That(cachedParameters[3].ParameterType, Is.EqualTo(typeof(string))); + Assert.That(cachedParameters[4].ParameterType, Is.EqualTo(typeof(string))); + Assert.That( + cachedParameters[5].ParameterType.GetGenericArguments()[0], + Is.EqualTo(typeof(string))); + Assert.That( + cachedParameters[6].ParameterType, + Is.EqualTo(typeof(uint).MakeByRefType())); + Assert.That(fileSystemParameters, Has.Length.EqualTo(5)); + Assert.That( + fileSystemParameters[3].ParameterType.GetGenericArguments()[0].FullName, + Is.EqualTo("Opc.Ua.NodeId")); + Assert.That( + fileSystemParameters[4].ParameterType, + Is.EqualTo(typeof(uint).MakeByRefType())); + Assert.That( + cachedState.GetProperty("GetUpdateBehavior")?.PropertyType.Name, + Is.EqualTo("GetUpdateBehaviorCachedLoadingMethodState")); + Assert.That( + fileSystemState.GetProperty("GetUpdateBehavior")?.PropertyType.Name, + Is.EqualTo("GetUpdateBehaviorFileSystemMethodState")); + }); + } + + private static Type GetRoboticsType(string name) + { + return typeof(RoboticsModel).Assembly.GetType( + "Opc.Ua.Robotics." + name, + throwOnError: true)!; + } + + private static Type GetDiType(string name) + { + return typeof(Opc.Ua.Di.Namespaces).Assembly.GetType( + "Opc.Ua.Di." + name, + throwOnError: true)!; + } + + private static MethodInfo GetInvoke(string delegateName) + { + return GetRoboticsType(delegateName).GetMethod("Invoke")!; + } + + private static MethodInfo GetDiInvoke(string delegateName) + { + return GetDiType(delegateName).GetMethod("Invoke")!; + } + + private static void AssertAsyncResult(Type returnType, Type resultType) + { + Assert.Multiple(() => + { + Assert.That(returnType.IsGenericType, Is.True); + Assert.That( + returnType.GetGenericTypeDefinition(), + Is.EqualTo(typeof(ValueTask<>))); + Assert.That(returnType.GetGenericArguments()[0], Is.EqualTo(resultType)); + }); + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/Opc.Ua.Robotics.Tests.csproj b/tests/Opc.Ua.Robotics.Tests/Opc.Ua.Robotics.Tests.csproj new file mode 100644 index 0000000000..84b947c29b --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/Opc.Ua.Robotics.Tests.csproj @@ -0,0 +1,36 @@ + + + $(TestsTargetFrameworks) + Opc.Ua.Robotics.Tests + Opc.Ua.Robotics.Tests + enable + false + $(NoWarn);CS1591;CA2007;CA2000;CA1014;CA1861 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/tests/Opc.Ua.Robotics.Tests/RoboticsClientFacadeTests.cs b/tests/Opc.Ua.Robotics.Tests/RoboticsClientFacadeTests.cs new file mode 100644 index 0000000000..7492691650 --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/RoboticsClientFacadeTests.cs @@ -0,0 +1,855 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.Client.FileSystem; +using Opc.Ua.Client.Subscriptions; +using Opc.Ua.Client.Subscriptions.Streaming; +using RoboticsBrowseNames = Opc.Ua.Robotics.BrowseNames; +using DiBrowseNames = Opc.Ua.Di.BrowseNames; + +namespace Opc.Ua.Robotics.Client.Tests +{ + /// + /// Tests for the high-level Robotics facade added for topology, operations, streaming, and DI. + /// + [TestFixture] + [Category("Robotics")] + public sealed class RoboticsClientFacadeTests + { + [Test] + public async Task EnumerateMotionDeviceSystemsStreamsMatchesAndUsesBrowseNext() + { + RoboticsSessionHarness harness = new(); + harness.AddBrowse( + harness.DeviceSetId, + [harness.Ref(harness.SystemId, "System", RoboticsModel.MotionDeviceSystemType)]); + harness.EnableBrowseContinuationFor(harness.DeviceSetId); + harness.NodeCache.Setup(c => c.IsTypeOfAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask(true)); + + List entries = []; + await foreach (MotionDeviceSystemEntry entry in harness.Client.EnumerateMotionDeviceSystemsAsync()) + { + entries.Add(entry); + } + + Assert.That(entries, Has.Count.EqualTo(1)); + Assert.That(entries[0].NodeId, Is.EqualTo(harness.SystemId)); + harness.Session.Verify(s => s.BrowseNextAsync( + It.IsAny(), false, It.IsAny>(), It.IsAny()), + Times.Once); + } + + [Test] + public async Task EnumerateMotionDeviceSystemsReturnsEmptyWhenNamespaceAbsent() + { + var session = new Mock(); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + var client = new RoboticsClient(session.Object, new Mock().Object); + + List entries = []; + await foreach (MotionDeviceSystemEntry entry in client.EnumerateMotionDeviceSystemsAsync()) + { + entries.Add(entry); + } + + Assert.That(entries, Is.Empty); + } + + [Test] + public async Task ReadSystemPopulatesTopologySnapshotsAndRelationships() + { + RoboticsSessionHarness h = new(); + h.ConfigureCompleteTopology(); + + RoboticsTopologySnapshot snapshot = await h.Client.ReadSystemAsync(h.SystemId).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(snapshot.Systems[0].Identification.ComponentName.Text, Is.EqualTo("System")); + Assert.That(snapshot.Controllers[0].TaskControlIds.ToList(), Does.Contain(h.TaskControlId)); + Assert.That(snapshot.MotionDevices[0].AxisIds.ToList(), Does.Contain(h.AxisId)); + Assert.That(snapshot.MotionDevices[0].FlangeLoadId, Is.EqualTo(h.FlangeLoadId)); + Assert.That( + snapshot.Axes[0].State.ActualPosition.WrappedValue.TryGetValue(out double position), + Is.True); + Assert.That(position, Is.EqualTo(12.5d)); + Assert.That(snapshot.Loads.ToList().Any(l => l.NodeId == h.FlangeLoadId), Is.True); + Assert.That(snapshot.PowerTrains[0].MotorIds.ToList(), Does.Contain(h.MotorId)); + Assert.That(snapshot.Motors[0].Identification.NodeId, Is.EqualTo(h.MotorId)); + Assert.That(snapshot.Gears[0].Identification.NodeId, Is.EqualTo(h.GearId)); + Assert.That(snapshot.Drives[0].Identification.NodeId, Is.EqualTo(h.DriveId)); + Assert.That(snapshot.SafetyStates[0].EmergencyStopFunctions[0].Name, Is.EqualTo("EStop")); + Assert.That(snapshot.TaskControls[0].TaskModuleIds.ToList(), Does.Contain(h.TaskModuleId)); + Assert.That(snapshot.TaskModules[0].Name, Is.EqualTo("Module")); + Assert.That(snapshot.Relationships.Controls, Has.Count.EqualTo(1)); + Assert.That(snapshot.Relationships.HasSafetyStates, Has.Count.EqualTo(1)); + Assert.That(snapshot.Relationships.Moves, Has.Count.EqualTo(1)); + Assert.That(snapshot.Relationships.Requires, Has.Count.EqualTo(1)); + Assert.That(snapshot.Relationships.HasSlave, Has.Count.EqualTo(1)); + Assert.That(snapshot.Relationships.IsDrivenBy, Has.Count.EqualTo(1)); + Assert.That(snapshot.Relationships.IsConnectedTo, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async Task PerNodeSnapshotReadersPopulateExpectedFields() + { + RoboticsSessionHarness h = new(); + h.ConfigureCompleteTopology(); + + ControllerSnapshot controller = await h.Client.ReadControllerAsync(h.ControllerId).ConfigureAwait(false); + MotionDeviceSnapshot motion = await h.Client.ReadMotionDeviceAsync(h.MotionDeviceId).ConfigureAwait(false); + AxisSnapshot axis = await h.Client.ReadAxisAsync(h.AxisId).ConfigureAwait(false); + SafetyStateSnapshot safety = await h.Client.ReadSafetyStateAsync(h.SafetyId).ConfigureAwait(false); + TaskControlSnapshot task = await h.Client.ReadTaskControlAsync(h.TaskControlId).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(controller.ComponentIds.ToList(), Does.Contain(h.ControllerComponentId)); + Assert.That(motion.SpeedOverride.WrappedValue.TryGetValue(out double speed), Is.True); + Assert.That(speed, Is.EqualTo(0.75d)); + Assert.That(axis.MotionProfile, Is.EqualTo(AxisMotionProfileEnumeration.ROTARY)); + Assert.That(safety.ProtectiveStopFunctions[0].Name, Is.EqualTo("PStop")); + Assert.That(task.TaskControlOperationId, Is.EqualTo(h.TaskControlOperationId)); + }); + } + + [Test] + public async Task ProgramsAsyncReturnsFileSystemClientAndFailsWhenAbsent() + { + RoboticsSessionHarness h = new(); + h.AddChild(h.ControllerId, RoboticsBrowseNames.Programs, h.ProgramsId); + + FileSystemClient fileSystem = await h.Client.ProgramsAsync(h.ControllerId).ConfigureAwait(false); + + Assert.That(fileSystem.Root.NodeId, Is.EqualTo(h.ProgramsId)); + RoboticsSessionHarness missing = new(); + ServiceResultException ex = Assert.ThrowsAsync( + async () => await missing.Client.ProgramsAsync(missing.ControllerId).ConfigureAwait(false))!; + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNotFound)); + } + + [TestCase("GetReady")] + [TestCase("Start")] + [TestCase("Stop")] + [TestCase("StandDown")] + public async Task SystemOperationVerbsReturnStatusAndBadResultsThrow(string verb) + { + RoboticsSessionHarness good = new(); + good.ConfigureSystemOperation(StatusCodes.Good, 17); + SystemOperationClient goodClient = new(good.Session.Object, good.SystemOperationId, good.Telemetry); + + int status = await InvokeSystemVerbAsync(goodClient, verb).ConfigureAwait(false); + + Assert.That(status, Is.EqualTo(17)); + RoboticsSessionHarness bad = new(); + bad.ConfigureSystemOperation(StatusCodes.BadInvalidState, 0); + SystemOperationClient badClient = new(bad.Session.Object, bad.SystemOperationId, bad.Telemetry); + ServiceResultException ex = Assert.ThrowsAsync( + async () => await InvokeSystemVerbAsync(badClient, verb).ConfigureAwait(false))!; + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + } + + [TestCase("LoadByName")] + [TestCase("LoadByNodeId")] + [TestCase("UnloadByName")] + [TestCase("UnloadByNodeId")] + [TestCase("UnloadProgram")] + [TestCase("ResetToProgramStart")] + [TestCase("Start")] + [TestCase("Stop")] + public async Task TaskControlMembersReturnStatusAndBadResultsThrow(string verb) + { + RoboticsSessionHarness good = new(); + good.ConfigureTaskControl(StatusCodes.Good, 23); + TaskControlClient goodClient = new(good.Session.Object, good.TaskControlId, good.Telemetry); + + int status = await InvokeTaskVerbAsync(goodClient, verb).ConfigureAwait(false); + + Assert.That(status, Is.EqualTo(23)); + RoboticsSessionHarness bad = new(); + bad.ConfigureTaskControl(StatusCodes.BadInvalidState, 0); + TaskControlClient badClient = new(bad.Session.Object, bad.TaskControlId, bad.Telemetry); + ServiceResultException ex = Assert.ThrowsAsync( + async () => await InvokeTaskVerbAsync(badClient, verb).ConfigureAwait(false))!; + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + } + + [Test] + public async Task OperationClientsReadAndObserveState() + { + RoboticsSessionHarness h = new(); + h.ConfigureSystemOperation(StatusCodes.Good, 1); + h.AddStateReads(h.SystemStateMachineId, RoboticsBrowseNames.Executing); + Mock streaming = h.Streaming(h.CurrentStateIdNode); + SystemOperationClient system = new(h.Session.Object, h.SystemOperationId, h.Telemetry); + + RoboticsOperationState read = await system.ReadStateAsync().ConfigureAwait(false); + RoboticsOperationState observed = await FirstAsync( + system.ObserveStateAsync(streaming.Object)).ConfigureAwait(false); + + Assert.That(read, Is.EqualTo(RoboticsOperationState.Executing)); + Assert.That(observed, Is.EqualTo(RoboticsOperationState.Executing)); + } + + [Test] + public async Task ObserveAxisAndSafetyYieldSnapshotsAndCancellationStopsEnumeration() + { + RoboticsSessionHarness h = new(); + h.ConfigureCompleteTopology(); + Mock streaming = h.Streaming(h.AxisPositionId); + + AxisStateSnapshot axis = await FirstAsync( + h.Client.ObserveAxisAsync(h.AxisId, streaming.Object)).ConfigureAwait(false); + SafetyStateSnapshot safety = await FirstAsync( + h.Client.ObserveSafetyAsync(h.SafetyId, streaming.Object)).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(axis.ActualPosition.WrappedValue.TryGetValue(out double position), Is.True); + Assert.That(position, Is.EqualTo(12.5d)); + Assert.That(safety.EmergencyStop.WrappedValue.TryGetValue(out bool emergency), Is.True); + Assert.That(emergency, Is.False); + }); + } + + [Test] + public void OperationCallsFailExplicitlyWhenRequiredNodesAreAbsent() + { + RoboticsSessionHarness h = new(); + SystemOperationClient system = new(h.Session.Object, h.SystemOperationId, h.Telemetry); + TaskControlClient task = new(h.Session.Object, h.TaskControlId, h.Telemetry); + + Assert.ThrowsAsync( + async () => await system.StartAsync().ConfigureAwait(false)); + Assert.ThrowsAsync( + async () => await task.StartAsync().ConfigureAwait(false)); + } + + [Test] + public async Task SessionExtensionFactoryAndRegistrationWorkAndValidateNulls() + { + RoboticsSessionHarness h = new(); + + RoboticsClient fromExtension = h.Session.Object.Robotics(h.Telemetry); + var factory = new RoboticsClientFactory( + _ => Task.FromResult((ManagedSession)h.Session.Object), h.Telemetry); + + Assert.That(fromExtension.Session, Is.SameAs(h.Session.Object)); + Assert.Throws(() => SessionRoboticsExtensions.Robotics(null!, h.Telemetry)); + Assert.Throws(() => h.Session.Object.Robotics(null!)); + Assert.Throws(() => new RoboticsClientFactory(null!, h.Telemetry)); + Assert.Throws(() => new RoboticsClientFactory(_ => null!, null!)); + + var services = new ServiceCollection(); + var builder = new TestClientBuilder(services); + builder.AddRoboticsClient(); + Assert.That(services.Any(s => s.ServiceType == typeof(RoboticsClientFactory)), Is.True); + await Task.CompletedTask.ConfigureAwait(false); + } + + private static async Task InvokeSystemVerbAsync(SystemOperationClient client, string verb) + { + return verb switch + { + "GetReady" => await client.GetReadyAsync().ConfigureAwait(false), + "Start" => await client.StartAsync().ConfigureAwait(false), + "Stop" => await client.StopAsync(RoboticsStopMode.Controlled).ConfigureAwait(false), + _ => await client.StandDownAsync().ConfigureAwait(false) + }; + } + + private static async Task InvokeTaskVerbAsync(TaskControlClient client, string verb) + { + return verb switch + { + "LoadByName" => (await client.LoadByNameAsync("p").ConfigureAwait(false)).Status, + "LoadByNodeId" => (await client.LoadByNodeIdAsync(new NodeId(1)).ConfigureAwait(false)).Status, + "UnloadByName" => (await client.UnloadByNameAsync("p").ConfigureAwait(false)).Status, + "UnloadByNodeId" => (await client.UnloadByNodeIdAsync(new NodeId(1)).ConfigureAwait(false)).Status, + "UnloadProgram" => (await client.UnloadProgramAsync().ConfigureAwait(false)).Status, + "ResetToProgramStart" => await client.ResetToProgramStartAsync().ConfigureAwait(false), + "Start" => await client.StartAsync().ConfigureAwait(false), + _ => await client.StopAsync(RoboticsStopMode.Quick).ConfigureAwait(false) + }; + } + + private static async Task FirstAsync(IAsyncEnumerable source) + { + await foreach (T item in source.ConfigureAwait(false)) + { + return item; + } + throw new InvalidOperationException("The sequence did not produce a value."); + } + + private sealed class TestClientBuilder(IServiceCollection services) : IOpcUaClientBuilder + { + public IServiceCollection Services { get; } = services; + } + + private sealed class RoboticsSessionHarness + { + private readonly Dictionary<(NodeId Parent, string BrowseName), NodeId> m_children = []; + private readonly Dictionary> m_browse = []; + private readonly Dictionary m_values = []; + private readonly HashSet m_continuationNodes = []; + private StatusCode m_callStatus = StatusCodes.Good; + private int m_callOutput; + + public RoboticsSessionHarness() + { + Telemetry = new Mock().Object; + NamespaceUris.GetIndexOrAppend(global::Opc.Ua.Robotics.Namespaces.Robotics); + NamespaceUris.GetIndexOrAppend(Opc.Ua.Di.Namespaces.OpcUaDi); + MessageContext = ServiceMessageContext.Create(Telemetry); + MessageContext.NamespaceUris.GetIndexOrAppend(global::Opc.Ua.Robotics.Namespaces.Robotics); + MessageContext.NamespaceUris.GetIndexOrAppend(Opc.Ua.Di.Namespaces.OpcUaDi); + Session.SetupGet(s => s.NamespaceUris).Returns(NamespaceUris); + Session.SetupGet(s => s.MessageContext).Returns(MessageContext); + Session.SetupGet(s => s.Factory).Returns(MessageContext.Factory); + Session.SetupGet(s => s.OperationLimits).Returns(new OperationLimits()); + Session.SetupGet(s => s.ServerCapabilities).Returns(new ServerCapabilities()); + Session.SetupGet(s => s.ContinuationPointPolicy).Returns(ContinuationPointPolicy.Default); + Session.SetupGet(s => s.NodeCache).Returns(NodeCache.Object); + NodeCache.Setup(c => c.IsTypeOfAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask(true)); + SetupTranslate(); + SetupBrowse(); + SetupRead(); + SetupCall(); + Client = new RoboticsClient(Session.Object, Telemetry); + } + + public Mock Session { get; } = new(MockBehavior.Loose); + + public Mock NodeCache { get; } = new(MockBehavior.Loose); + + public ITelemetryContext Telemetry { get; } + + public NamespaceTable NamespaceUris { get; } = new(); + + public ServiceMessageContext MessageContext { get; } + + public RoboticsClient Client { get; } + + public NodeId DeviceSetId => NodeId.Create( + Opc.Ua.Di.Objects.DeviceSet, Opc.Ua.Di.Namespaces.OpcUaDi, NamespaceUris); + + public NodeId SystemId { get; } = new(1001, 2); + + public NodeId ControllersFolderId { get; } = new(1002, 2); + + public NodeId MotionDevicesFolderId { get; } = new(1003, 2); + + public NodeId SafetyStatesFolderId { get; } = new(1004, 2); + + public NodeId ControllerId { get; } = new(1100, 2); + + public NodeId ControllerComponentId { get; } = new(1101, 2); + + public NodeId ControllerComponentsFolderId { get; } = new(1102, 2); + + public NodeId TaskControlsFolderId { get; } = new(1103, 2); + + public NodeId SystemOperationId { get; } = new(1104, 2); + + public NodeId SystemStateMachineId { get; } = new(1105, 2); + + public NodeId ProgramsId { get; } = new(1106, 2); + + public NodeId MotionDeviceId { get; } = new(1200, 2); + + public NodeId AxesFolderId { get; } = new(1201, 2); + + public NodeId PowerTrainsFolderId { get; } = new(1202, 2); + + public NodeId AdditionalComponentsFolderId { get; } = new(1203, 2); + + public NodeId AxisId { get; } = new(1300, 2); + + public NodeId AxisPositionId { get; } = new(1301, 2); + + public NodeId AxisSpeedId { get; } = new(1302, 2); + + public NodeId AxisAccelerationId { get; } = new(1303, 2); + + public NodeId AxisMotionProfileId { get; } = new(1304, 2); + + public NodeId AxisLoadId { get; } = new(1305, 2); + + public NodeId FlangeLoadId { get; } = new(1306, 2); + + public NodeId PowerTrainId { get; } = new(1400, 2); + + public NodeId MotorId { get; } = new(1401, 2); + + public NodeId GearId { get; } = new(1402, 2); + + public NodeId DriveId { get; } = new(1403, 2); + + public NodeId SafetyId { get; } = new(1500, 2); + + public NodeId EmergencyFunctionsFolderId { get; } = new(1501, 2); + + public NodeId ProtectiveFunctionsFolderId { get; } = new(1502, 2); + + public NodeId EmergencyFunctionId { get; } = new(1503, 2); + + public NodeId ProtectiveFunctionId { get; } = new(1504, 2); + + public NodeId TaskControlId { get; } = new(1600, 2); + + public NodeId TaskControlOperationId { get; } = new(1601, 2); + + public NodeId TaskControlStateMachineId { get; } = new(1602, 2); + + public NodeId TaskModulesFolderId { get; } = new(1603, 2); + + public NodeId TaskModuleId { get; } = new(1604, 2); + + public NodeId CurrentStateNode { get; } = new(1700, 2); + + public NodeId CurrentStateIdNode { get; } = new(1701, 2); + + public void ConfigureCompleteTopology() + { + AddChild(SystemId, RoboticsBrowseNames.Controllers, ControllersFolderId); + AddChild(SystemId, RoboticsBrowseNames.MotionDevices, MotionDevicesFolderId); + AddChild(SystemId, RoboticsBrowseNames.SafetyStates, SafetyStatesFolderId); + AddBrowse(ControllersFolderId, [Ref(ControllerId, "Controller", RoboticsModel.ControllerType)]); + AddBrowse(MotionDevicesFolderId, [Ref(MotionDeviceId, "Motion", RoboticsModel.MotionDeviceType)]); + AddBrowse(SafetyStatesFolderId, [Ref(SafetyId, "Safety", ObjectTypes.SafetyStateType)]); + + AddIdentification(SystemId, "System"); + AddIdentification(ControllerId, "Controller"); + AddChild(ControllerId, RoboticsBrowseNames.TaskControls, TaskControlsFolderId); + AddChild(ControllerId, RoboticsBrowseNames.Components, ControllerComponentsFolderId); + AddChild(ControllerId, RoboticsBrowseNames.Programs, ProgramsId); + AddBrowse(TaskControlsFolderId, [Ref(TaskControlId, "Task", ObjectTypes.TaskControlType)]); + AddBrowse( + ControllerComponentsFolderId, + [Ref(ControllerComponentId, "Component", ObjectTypes.AuxiliaryComponentType)]); + + AddIdentification(MotionDeviceId, "Motion"); + AddChild(MotionDeviceId, RoboticsBrowseNames.Axes, AxesFolderId); + AddChild(MotionDeviceId, RoboticsBrowseNames.PowerTrains, PowerTrainsFolderId); + AddChild(MotionDeviceId, RoboticsBrowseNames.AdditionalComponents, AdditionalComponentsFolderId); + AddChild(MotionDeviceId, RoboticsBrowseNames.FlangeLoad, FlangeLoadId); + AddValueChild(MotionDeviceId, RoboticsBrowseNames.MotionDeviceCategory, + (int)MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT); + AddValueChild(MotionDeviceId, RoboticsBrowseNames.SpeedOverride, 0.75d); + AddBrowse(AxesFolderId, [Ref(AxisId, "Axis", RoboticsModel.AxisType)]); + AddBrowse(PowerTrainsFolderId, [Ref(PowerTrainId, "PowerTrain", ObjectTypes.PowerTrainType)]); + AddBrowse(AdditionalComponentsFolderId, []); + + AddIdentification(AxisId, "Axis"); + AddChild(AxisId, RoboticsBrowseNames.AdditionalLoad, AxisLoadId); + AddValueChild(AxisId, RoboticsBrowseNames.ActualPosition, 12.5d, AxisPositionId); + AddValueChild(AxisId, RoboticsBrowseNames.ActualSpeed, 3.0d, AxisSpeedId); + AddValueChild(AxisId, RoboticsBrowseNames.ActualAcceleration, 1.5d, AxisAccelerationId); + AddValueChild(AxisId, RoboticsBrowseNames.MotionProfile, (int)AxisMotionProfileEnumeration.ROTARY, + AxisMotionProfileId); + AddLoad(AxisLoadId); + AddLoad(FlangeLoadId); + + AddIdentification(PowerTrainId, "PowerTrain"); + AddChild(PowerTrainId, RoboticsBrowseNames.MotorIdentifier_Placeholder, MotorId); + AddChild(PowerTrainId, "MotorIdentifier_Placeholder", MotorId); + AddChild(PowerTrainId, RoboticsBrowseNames.GearIdentifier_Placeholder, GearId); + AddChild(PowerTrainId, "GearIdentifier_Placeholder", GearId); + AddIdentification(MotorId, "Motor"); + AddChild(MotorId, RoboticsBrowseNames.DriveIdentifier_Placeholder, DriveId); + AddChild(MotorId, "DriveIdentifier_Placeholder", DriveId); + AddIdentification(GearId, "Gear"); + AddValueChild(GearId, RoboticsBrowseNames.Pitch, 1.0d); + AddIdentification(DriveId, "Drive"); + + AddIdentification(SafetyId, "Safety"); + AddChild(SafetyId, RoboticsBrowseNames.EmergencyStopFunctions, EmergencyFunctionsFolderId); + AddChild(SafetyId, RoboticsBrowseNames.ProtectiveStopFunctions, ProtectiveFunctionsFolderId); + AddValueChild(SafetyId, RoboticsBrowseNames.EmergencyStop, false); + AddValueChild(SafetyId, RoboticsBrowseNames.OperationalMode, 1); + AddValueChild(SafetyId, RoboticsBrowseNames.ProtectiveStop, true); + AddBrowse( + EmergencyFunctionsFolderId, + [Ref(EmergencyFunctionId, "EStop", ObjectTypes.EmergencyStopFunctionType)]); + AddBrowse( + ProtectiveFunctionsFolderId, + [Ref(ProtectiveFunctionId, "PStop", ObjectTypes.ProtectiveStopFunctionType)]); + AddSafetyFunction(EmergencyFunctionId, "EStop"); + AddSafetyFunction(ProtectiveFunctionId, "PStop"); + + AddIdentification(TaskControlId, "TaskControl"); + AddChild(TaskControlId, RoboticsBrowseNames.TaskControlOperation, TaskControlOperationId); + AddChild(TaskControlId, RoboticsBrowseNames.TaskModules, TaskModulesFolderId); + AddValueChild(TaskControlId, RoboticsBrowseNames.ExecutionMode, 1); + AddValueChild(TaskControlId, RoboticsBrowseNames.TaskProgramLoaded, true); + AddValueChild(TaskControlId, RoboticsBrowseNames.TaskProgramName, "Program"); + AddBrowse(TaskModulesFolderId, [Ref(TaskModuleId, "Module", ObjectTypes.TaskModuleType)]); + AddValueChild(TaskModuleId, RoboticsBrowseNames.Name, "Module"); + AddValueChild(TaskModuleId, "Version", "1.0"); + AddValueChild(TaskModuleId, RoboticsBrowseNames.IsReferenced, true); + + AddRelationship(ControllerId, ReferenceTypes.Controls, MotionDeviceId); + AddRelationship(ControllerId, ReferenceTypes.HasSafetyStates, SafetyId); + AddRelationship(PowerTrainId, ReferenceTypes.Moves, AxisId); + AddRelationship(AxisId, ReferenceTypes.Requires, PowerTrainId); + AddRelationship(PowerTrainId, ReferenceTypes.HasSlave, PowerTrainId); + AddRelationship(MotorId, ReferenceTypes.IsDrivenBy, DriveId); + AddRelationship(MotionDeviceId, ReferenceTypes.IsConnectedTo, ControllerId); + } + + public void ConfigureSystemOperation(StatusCode statusCode, int output) + { + m_callStatus = statusCode; + m_callOutput = output; + AddChild(SystemOperationId, RoboticsBrowseNames.SystemOperationStateMachine, SystemStateMachineId); + AddStateReads(SystemStateMachineId, RoboticsBrowseNames.Ready); + } + + public void ConfigureTaskControl(StatusCode statusCode, int output) + { + m_callStatus = statusCode; + m_callOutput = output; + AddChild(TaskControlId, RoboticsBrowseNames.TaskControlOperation, TaskControlOperationId); + AddChild( + TaskControlOperationId, + RoboticsBrowseNames.TaskControlStateMachine, + TaskControlStateMachineId); + AddChild(TaskControlStateMachineId, RoboticsBrowseNames.Ready, new NodeId(1605, 2)); + AddStateReads(TaskControlStateMachineId, RoboticsBrowseNames.Ready); + } + + public void AddStateReads(NodeId stateMachine, string stateName) + { + AddChild(stateMachine, Opc.Ua.BrowseNames.CurrentState, CurrentStateNode); + AddChild(CurrentStateNode, Opc.Ua.BrowseNames.Id, CurrentStateIdNode); + m_values[CurrentStateNode] = Variant.From(new LocalizedText(stateName)); + m_values[CurrentStateIdNode] = Variant.From(new NodeId(999, 2)); + } + + public Mock Streaming(NodeId nodeId) + { + var streaming = new Mock(); + streaming.Setup(s => s.SubscribeDataChangesAsync( + It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(SingleChange(nodeId)); + streaming.Setup(s => s.SubscribeDataChangesAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(SingleChange(nodeId)); + return streaming; + } + + public ReferenceDescription Ref(NodeId nodeId, string browseName, uint typeId) + { + return new ReferenceDescription + { + NodeId = new ExpandedNodeId(nodeId), + BrowseName = new QualifiedName(browseName, 2), + DisplayName = new LocalizedText(browseName), + NodeClass = NodeClass.Object, + TypeDefinition = new ExpandedNodeId(new NodeId(typeId, 2)), + ReferenceTypeId = Opc.Ua.ReferenceTypeIds.HierarchicalReferences, + IsForward = true + }; + } + + public void AddBrowse(NodeId folder, IReadOnlyList references) + { + m_browse[folder] = [.. references]; + } + + public void EnableBrowseContinuationFor(NodeId folder) + { + m_continuationNodes.Add(folder); + } + + public void AddChild(NodeId parent, string browseName, NodeId child) + { + m_children[(parent, browseName)] = child; + } + + private void AddValueChild(NodeId parent, string browseName, object value) + { + AddValueChild( + parent, browseName, value, + new NodeId((uint)Math.Abs(HashCode.Combine(parent, browseName)), 2)); + } + + private void AddValueChild(NodeId parent, string browseName, object value, NodeId nodeId) + { + AddChild(parent, browseName, nodeId); + m_values[nodeId] = ToVariant(value); + } + + private void AddIdentification(NodeId nodeId, string name) + { + AddValueChild(nodeId, DiBrowseNames.ComponentName, new LocalizedText(name)); + AddValueChild(nodeId, DiBrowseNames.AssetId, name + "Asset"); + AddValueChild(nodeId, DiBrowseNames.Manufacturer, new LocalizedText("OPC")); + AddValueChild(nodeId, DiBrowseNames.Model, new LocalizedText(name + "Model")); + AddValueChild(nodeId, DiBrowseNames.ProductCode, name + "Code"); + AddValueChild(nodeId, DiBrowseNames.SerialNumber, name + "Serial"); + AddValueChild(nodeId, DiBrowseNames.DeviceManual, name + "Manual"); + } + + private void AddLoad(NodeId load) + { + AddValueChild(load, RoboticsBrowseNames.Mass, 10.0d); + AddValueChild(load, RoboticsBrowseNames.CenterOfMass, "center"); + AddValueChild(load, RoboticsBrowseNames.Inertia, "inertia"); + } + + private void AddSafetyFunction(NodeId nodeId, string name) + { + AddValueChild(nodeId, RoboticsBrowseNames.Name, name); + AddValueChild(nodeId, RoboticsBrowseNames.Active, true); + AddValueChild(nodeId, RoboticsBrowseNames.Enabled, true); + } + + private void AddRelationship(NodeId source, uint referenceType, NodeId target) + { + NodeId referenceTypeId = new( + referenceType, + (ushort)NamespaceUris.GetIndex(global::Opc.Ua.Robotics.Namespaces.Robotics)); + if (!m_browse.TryGetValue(source, out List? list)) + { + list = []; + m_browse[source] = list; + } + list.Add(new ReferenceDescription + { + NodeId = new ExpandedNodeId(target), + BrowseName = new QualifiedName("rel", 2), + DisplayName = new LocalizedText("rel"), + ReferenceTypeId = referenceTypeId, + IsForward = true, + NodeClass = NodeClass.Object + }); + } + + private void SetupTranslate() + { + Session.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns, CancellationToken>((_, paths, _) => + { + var results = new List(); + for (int ii = 0; ii < paths.Count; ii++) + { + BrowsePath path = paths[ii]; + NodeId current = path.StartingNode; + bool found = true; + for (int jj = 0; jj < path.RelativePath.Elements.Count; jj++) + { + string name = path.RelativePath.Elements[jj].TargetName.Name ?? string.Empty; + if (!m_children.TryGetValue((current, name), out NodeId next)) + { + found = false; + break; + } + current = next; + } + results.Add(found ? GoodPath(current) : BadPath()); + } + return new ValueTask( + new TranslateBrowsePathsToNodeIdsResponse + { + ResponseHeader = new ResponseHeader(), + Results = results.ToArrayOf(), + DiagnosticInfos = default + }); + }); + } + + private void SetupBrowse() + { + Session.Setup(s => s.BrowseAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Returns, CancellationToken>( + (_, _, _, descriptions, _) => + { + BrowseDescription description = descriptions[0]; + List refs = m_browse.TryGetValue( + description.NodeId, out List? value) + ? value.Where(r => + description.ReferenceTypeId.IsNull || + r.ReferenceTypeId == description.ReferenceTypeId || + description.ReferenceTypeId == + Opc.Ua.ReferenceTypeIds.HierarchicalReferences).ToList() + : []; + bool continuation = m_continuationNodes.Remove(description.NodeId) && refs.Count > 0; + ArrayOf returned = continuation ? [refs[0]] : refs.ToArrayOf(); + return new ValueTask(new BrowseResponse + { + ResponseHeader = new ResponseHeader(), + Results = + [ + new BrowseResult + { + StatusCode = StatusCodes.Good, + References = returned, + ContinuationPoint = continuation ? new ByteString(new byte[] { 1 }) : default + } + ], + DiagnosticInfos = default + }); + }); + Session.Setup(s => s.BrowseNextAsync( + It.IsAny(), false, It.IsAny>(), It.IsAny())) + .Returns(new ValueTask(new BrowseNextResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new BrowseResult { StatusCode = StatusCodes.Good, References = [] }], + DiagnosticInfos = default + })); + } + + private void SetupRead() + { + Session.Setup(s => s.ReadAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Returns, CancellationToken>( + (_, _, _, nodes, _) => + { + var values = new List(); + for (int ii = 0; ii < nodes.Count; ii++) + { + ReadValueId node = nodes[ii]; + if (node.AttributeId == Attributes.BrowseName) + { + values.Add(Value(new QualifiedName( + "Browse" + ii.ToString(System.Globalization.CultureInfo.InvariantCulture), 2))); + } + else if (node.AttributeId == Attributes.DisplayName) + { + values.Add(Value(new LocalizedText( + "Display" + ii.ToString(System.Globalization.CultureInfo.InvariantCulture)))); + } + else + { + values.Add(m_values.TryGetValue(node.NodeId, out Variant variant) + ? Value(variant) + : new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown)); + } + } + return new ValueTask(new ReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = values.ToArrayOf(), + DiagnosticInfos = default + }); + }); + } + + private void SetupCall() + { + Session.Setup(s => s.CallAsync( + It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns, CancellationToken>((_, _, _) => + new ValueTask(new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = + [ + new CallMethodResult + { + StatusCode = m_callStatus, + OutputArguments = [Variant.From(m_callOutput)] + } + ], + DiagnosticInfos = default + })); + } + + private static async IAsyncEnumerable SingleChange(NodeId nodeId) + { + yield return new DataValueChange(null, Value(nodeId), null); + await Task.CompletedTask.ConfigureAwait(false); + } + + private static BrowsePathResult GoodPath(NodeId nodeId) + { + return new BrowsePathResult + { + StatusCode = StatusCodes.Good, + Targets = [new BrowsePathTarget { TargetId = new ExpandedNodeId(nodeId) }] + }; + } + + private static BrowsePathResult BadPath() + { + return new BrowsePathResult { StatusCode = StatusCodes.BadNoMatch, Targets = [] }; + } + + private static DataValue Value(object value) + { + return Value(ToVariant(value)); + } + + private static DataValue Value(Variant value) + { + return new DataValue(value, StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow); + } + + private static Variant ToVariant(object value) + { + return value switch + { + bool b => Variant.From(b), + int i => Variant.From(i), + double d => Variant.From(d), + string s => Variant.From(s), + NodeId n => Variant.From(n), + QualifiedName q => Variant.From(q), + LocalizedText l => Variant.From(l), + _ => Variant.Null + }; + } + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/RoboticsClientInstanceTests.cs b/tests/Opc.Ua.Robotics.Tests/RoboticsClientInstanceTests.cs new file mode 100644 index 0000000000..ee330ef1b1 --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/RoboticsClientInstanceTests.cs @@ -0,0 +1,218 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Robotics; +using Opc.Ua.Robotics.Client; + +namespace Opc.Ua.Robotics.Client.Tests +{ + /// + /// Unit tests for the instance surface of : the + /// Device Integration composition, subtype-aware discovery, and subtype-aware + /// classification. + /// + [TestFixture] + [Category("Robotics")] + public sealed class RoboticsClientInstanceTests + { + private const uint VendorMotionDeviceType = 40001u; + + private static NamespaceTable TableWithRobotics(out ushort roboticsIndex) + { + var table = new NamespaceTable(); + roboticsIndex = (ushort)table.GetIndexOrAppend(global::Opc.Ua.Robotics.Namespaces.Robotics); + return table; + } + + private static Mock SessionWithRobotics(out ushort roboticsIndex) + { + NamespaceTable table = TableWithRobotics(out roboticsIndex); + var session = new Mock(); + session.SetupGet(s => s.NamespaceUris).Returns(table); + return session; + } + + [Test] + public void ConstructorNullSessionThrows() + { + Assert.Throws( + () => new RoboticsClient(null!, new Mock().Object)); + } + + [Test] + public void ConstructorNullTelemetryThrows() + { + Mock session = SessionWithRobotics(out _); + + Assert.Throws( + () => new RoboticsClient(session.Object, null!)); + } + + [Test] + public void ConstructorExposesSessionTelemetryAndDiTopology() + { + Mock session = SessionWithRobotics(out _); + ITelemetryContext telemetry = new Mock().Object; + + var client = new RoboticsClient(session.Object, telemetry); + + Assert.That(client.Session, Is.SameAs(session.Object)); + Assert.That(client.Telemetry, Is.SameAs(telemetry)); + Assert.That(client.Topology, Is.Not.Null); + Assert.That(client.Topology.Session, Is.SameAs(session.Object)); + } + + [Test] + public async Task DiscoverMotionDevicesReturnsEmptyWhenNamespaceAbsent() + { + var session = new Mock(); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + var client = new RoboticsClient( + session.Object, new Mock().Object); + + ArrayOf result = await client + .DiscoverMotionDevicesAsync(NodeId.Null, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(result.Count, Is.Zero); + } + + [Test] + public async Task DiscoverControllersReturnsEmptyWhenNamespaceAbsent() + { + var session = new Mock(); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + var client = new RoboticsClient( + session.Object, new Mock().Object); + + ArrayOf result = await client + .DiscoverControllersAsync(NodeId.Null, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(result.Count, Is.Zero); + } + + [Test] + public async Task DiscoverAxesReturnsEmptyWhenNamespaceAbsent() + { + var session = new Mock(); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + var client = new RoboticsClient( + session.Object, new Mock().Object); + + ArrayOf result = await client + .DiscoverAxesAsync(NodeId.Null, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(result.Count, Is.Zero); + } + + [Test] + public async Task GetRoboticsTypeNameReturnsNullForNullTypeDefinition() + { + Mock session = SessionWithRobotics(out _); + var client = new RoboticsClient( + session.Object, new Mock().Object); + + string? name = await client + .GetRoboticsTypeNameAsync(NodeId.Null, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(name, Is.Null); + } + + [Test] + public async Task GetRoboticsTypeNameMatchesStandardTypeWithoutNodeCache() + { + Mock session = SessionWithRobotics(out ushort robotics); + var client = new RoboticsClient( + session.Object, new Mock().Object); + + string? name = await client + .GetRoboticsTypeNameAsync( + new NodeId(RoboticsModel.AxisType, robotics), CancellationToken.None) + .ConfigureAwait(false); + + // The exact match short-circuits before the node cache is consulted. + Assert.That(name, Is.EqualTo("Axis")); + } + + [Test] + public async Task GetRoboticsTypeNameResolvesVendorSubtypeToClosestStandardType() + { + Mock session = SessionWithRobotics(out ushort robotics); + var vendorType = new NodeId(VendorMotionDeviceType, robotics); + var nodeCache = new Mock(); + nodeCache + .Setup(c => c.IsTypeOfAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((NodeId sub, NodeId super, CancellationToken _) => + new ValueTask( + sub == vendorType && + super == new NodeId(RoboticsModel.MotionDeviceType, robotics))); + session.SetupGet(s => s.NodeCache).Returns(nodeCache.Object); + var client = new RoboticsClient( + session.Object, new Mock().Object); + + string? name = await client + .GetRoboticsTypeNameAsync(vendorType, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(name, Is.EqualTo("MotionDevice")); + } + + [Test] + public async Task GetRoboticsTypeNameReturnsNullForUnrelatedType() + { + Mock session = SessionWithRobotics(out ushort robotics); + var nodeCache = new Mock(); + nodeCache + .Setup(c => c.IsTypeOfAsync( + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask(false)); + session.SetupGet(s => s.NodeCache).Returns(nodeCache.Object); + var client = new RoboticsClient( + session.Object, new Mock().Object); + + string? name = await client + .GetRoboticsTypeNameAsync( + new NodeId(9999u, robotics), CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(name, Is.Null); + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/RoboticsClientTests.cs b/tests/Opc.Ua.Robotics.Tests/RoboticsClientTests.cs new file mode 100644 index 0000000000..755e9088c1 --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/RoboticsClientTests.cs @@ -0,0 +1,148 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using NUnit.Framework; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Robotics; +using Opc.Ua.Robotics.Client; + +namespace Opc.Ua.Robotics.Client.Tests +{ + /// + /// Unit tests for and type + /// resolution, including the namespace-absent safety behaviour. + /// + [TestFixture] + [Category("Robotics")] + public sealed class RoboticsClientTests + { + private static NamespaceTable TableWithRobotics(out ushort roboticsIndex) + { + var table = new NamespaceTable(); + roboticsIndex = (ushort)table.GetIndexOrAppend(global::Opc.Ua.Robotics.Namespaces.Robotics); + return table; + } + + [Test] + public void TypeNodeIdResolvesInRoboticsNamespace() + { + NamespaceTable table = TableWithRobotics(out ushort index); + + NodeId node = RoboticsModel.TypeNodeId(RoboticsModel.MotionDeviceSystemType, table); + + Assert.That(node, Is.EqualTo(new NodeId(RoboticsModel.MotionDeviceSystemType, index))); + } + + [Test] + public void TypeNodeIdNullTableThrows() + { + Assert.Throws( + () => RoboticsModel.TypeNodeId(RoboticsModel.MotionDeviceSystemType, null!)); + } + + [Test] + public void TypeNodeIdThrowsWhenNamespaceAbsent() + { + // Documents why the Try* / discovery helpers must not funnel through TypeNodeId: + // NodeId.Create throws BadNodeIdInvalid when the namespace is not in the table. + Assert.Throws( + () => RoboticsModel.TypeNodeId(RoboticsModel.MotionDeviceSystemType, new NamespaceTable())); + } + + [TestCase(RoboticsModel.MotionDeviceSystemType, "MotionDeviceSystem")] + [TestCase(RoboticsModel.MotionDeviceType, "MotionDevice")] + [TestCase(RoboticsModel.AxisType, "Axis")] + [TestCase(RoboticsModel.ControllerType, "Controller")] + public void TryGetRoboticsTypeNameReturnsFriendlyName(uint typeId, string expected) + { + NamespaceTable table = TableWithRobotics(out ushort index); + var typeDefinition = new NodeId(typeId, index); + + bool matched = RoboticsClient.TryGetRoboticsTypeName(typeDefinition, table, out string? name); + + Assert.That(matched, Is.True); + Assert.That(name, Is.EqualTo(expected)); + } + + [Test] + public void TryGetRoboticsTypeNameUnknownTypeReturnsFalse() + { + NamespaceTable table = TableWithRobotics(out ushort index); + var typeDefinition = new NodeId(9999u, index); + + bool matched = RoboticsClient.TryGetRoboticsTypeName(typeDefinition, table, out string? name); + + Assert.That(matched, Is.False); + Assert.That(name, Is.Null); + } + + [Test] + public void TryGetRoboticsTypeNameReturnsFalseWhenNamespaceAbsent() + { + // The Robotics namespace is not in the table: the method must return false + // rather than throw (regression guard for the namespace-absent fix). + var table = new NamespaceTable(); + var typeDefinition = new NodeId(RoboticsModel.MotionDeviceSystemType, 3); + + bool matched = RoboticsClient.TryGetRoboticsTypeName(typeDefinition, table, out string? name); + + Assert.That(matched, Is.False); + Assert.That(name, Is.Null); + } + + [Test] + public void TryGetRoboticsTypeNameNullTableThrows() + { + Assert.Throws( + () => RoboticsClient.TryGetRoboticsTypeName(NodeId.Null, null!, out _)); + } + + [Test] + public void DiscoverMotionDeviceSystemsNullSessionThrows() + { + Assert.ThrowsAsync( + () => RoboticsClient.DiscoverMotionDeviceSystemsAsync(null!, NodeId.Null, CancellationToken.None)); + } + + [Test] + public async System.Threading.Tasks.Task DiscoverMotionDeviceSystemsReturnsEmptyWhenNamespaceAbsent() + { + var session = new Moq.Mock(); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + + ArrayOf result = await RoboticsClient.DiscoverMotionDeviceSystemsAsync( + session.Object, NodeId.Null, CancellationToken.None).ConfigureAwait(false); + + Assert.That(result.Count, Is.Zero); + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/RoboticsOperationBuilderTests.cs b/tests/Opc.Ua.Robotics.Tests/RoboticsOperationBuilderTests.cs new file mode 100644 index 0000000000..9625e24075 --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/RoboticsOperationBuilderTests.cs @@ -0,0 +1,263 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Robotics; +using Opc.Ua.Robotics.Server.Builders; + +namespace Opc.Ua.Robotics.Server.Tests +{ + [TestFixture] + [NonParallelizable] + [Category("Robotics")] + public sealed class RoboticsOperationBuilderTests + { + private RoboticsServerFixture m_fixture = null!; + private int m_nameCounter; + + [OneTimeSetUp] + public async Task SetUpAsync() + { + m_fixture = new RoboticsServerFixture(); + await m_fixture.StartAsync().ConfigureAwait(false); + } + + [OneTimeTearDown] + public async Task TearDownAsync() + { + await m_fixture.DisposeAsync().ConfigureAwait(false); + } + + [Test] + public async Task OperationFacetsDriveStateAndWireTaskControlReference() + { + SystemOperationBuilder systemOperation = null!; + TaskControlOperationBuilder taskOperation = null!; + MotionDeviceState motionState = null!; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("Cell"), system => + { + IMotionDeviceBuilder motion = system.AddMotionDevice("Robot", robot => + { + robot.WithCategory(MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT) + .WithSpeedOverride(50); + robot.AddAxis("Axis1", axis => axis + .AsVirtual() + .WithMotionProfile(AxisMotionProfileEnumeration.ROTARY) + .WithActualPosition(0)); + robot.AddPowerTrain("PowerTrain1", powerTrain => + powerTrain.AddMotor("Motor1", motor => motor.WithMotorTemperature(20))); + motionState = robot.State; + }); + system.AddSafetyState("Safety"); + system.AddController("Controller", controller => + { + controller.WithCurrentUser(user => user.WithLevel("Operator").WithName("alice")); + controller.AddSoftware("Software"); + systemOperation = (SystemOperationBuilder)controller.AddSystemOperation(operation => operation + .WithTransitionReason(7) + .OnGetReady(GoodOperation) + .OnStart(GoodOperation) + .OnStop(GoodStop) + .OnStandDown(GoodOperation)); + controller.AddTaskControl("Task", task => + { + task.WithComponentName("Task") + .WithTaskProgramLoaded(false) + .WithTaskProgramName(string.Empty) + .Controls(motion); + taskOperation = (TaskControlOperationBuilder)task.AddTaskControlOperation( + operation => operation + .WithMotionDevicesUnderControl([motion.State.NodeId]) + .OnLoadByName((_, _) => + new ValueTask(new RoboticsProgramResult())) + .OnStart(GoodOperation) + .OnStop(GoodStop)); + }); + }); + }).ConfigureAwait(false); + + await systemOperation.Machine.GetReady!.OnCallAsync!( + m_fixture.Manager.SystemContext, + systemOperation.Machine.GetReady, + systemOperation.State.NodeId, + CancellationToken.None).ConfigureAwait(false); + await systemOperation.Machine.Start!.OnCallAsync!( + m_fixture.Manager.SystemContext, + systemOperation.Machine.Start, + systemOperation.State.NodeId, + CancellationToken.None).ConfigureAwait(false); + await systemOperation.Machine.Stop!.OnCallAsync!( + m_fixture.Manager.SystemContext, + systemOperation.Machine.Stop, + systemOperation.State.NodeId, + 0, + CancellationToken.None).ConfigureAwait(false); + await taskOperation.Machine.LoadByName!.OnCallAsync!( + m_fixture.Manager.SystemContext, + taskOperation.Machine.LoadByName, + taskOperation.State.NodeId, + "Program", + CancellationToken.None).ConfigureAwait(false); + + Assert.That(systemOperation.Machine.CurrentState!.Value.Text, Is.EqualTo("Ready")); + Assert.That( + systemOperation.Machine.CurrentState.Number!.Value, + Is.EqualTo(2u)); + Assert.That(systemOperation.Machine.LastTransition!.Value.Text, Is.EqualTo("ExecutingToReady")); + Assert.That( + systemOperation.Machine.LastTransition.Number!.Value, + Is.EqualTo(5u)); + Assert.That(systemOperation.Machine.LastTransitionReason!.Value, Is.EqualTo(7)); + Assert.That(taskOperation.Machine.CurrentState!.Value.Text, Is.EqualTo("Ready")); + Assert.That( + taskOperation.Machine.CurrentState.Number!.Value, + Is.EqualTo(2u)); + Assert.That(taskOperation.Machine.LastTransition!.Value.Text, Is.EqualTo("IdleToReady")); + Assert.That( + taskOperation.Machine.LastTransition.Number!.Value, + Is.EqualTo(2u)); + Assert.That(motionState.TaskControlReference!.Value, Is.EqualTo(taskOperation.State.NodeId)); + Assert.That(taskOperation.State.MotionDevicesUnderControl!.Value.Span[0], Is.EqualTo(motionState.NodeId)); + } + + [Test] + public async Task AConcurrentTransitionIsRejectedWhileAHandlerIsRunning() + { + SystemOperationBuilder systemOperation = null!; + var handlerEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseHandler = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("Cell"), system => + { + IMotionDeviceBuilder motion = system.AddMotionDevice("Robot", robot => + { + robot.WithCategory(MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT); + robot.AddAxis("Axis1", axis => axis + .AsVirtual() + .WithMotionProfile(AxisMotionProfileEnumeration.ROTARY) + .WithActualPosition(0)); + robot.AddPowerTrain("PowerTrain1", powerTrain => + powerTrain.AddMotor("Motor1", motor => motor.WithMotorTemperature(20))); + }); + system.AddSafetyState("Safety"); + system.AddController("Controller", controller => + { + controller.WithCurrentUser(user => user.WithLevel("Operator").WithName("alice")); + controller.AddSoftware("Software"); + systemOperation = (SystemOperationBuilder)controller.AddSystemOperation( + operation => operation + .OnGetReady(async (_, _) => + { + handlerEntered.TrySetResult(true); + await releaseHandler.Task.ConfigureAwait(false); + return ServiceResult.Good; + }) + .OnStart(GoodOperation) + .OnStop(GoodStop) + .OnStandDown(GoodOperation)); + controller.AddTaskControl("Task", task => + { + task.WithComponentName("Task") + .WithTaskProgramLoaded(false) + .WithTaskProgramName(string.Empty) + .Controls(motion); + task.AddTaskControlOperation(operation => operation + .WithMotionDevicesUnderControl([motion.State.NodeId]) + .OnStart(GoodOperation) + .OnStop(GoodStop)); + }); + }); + }).ConfigureAwait(false); + + // GetReady is in flight and its handler is deliberately parked, so + // the machine is still Idle. Without an interlock a second caller + // passes the same guard and both handlers run against one machine. + Task getReady = systemOperation.Machine.GetReady!.OnCallAsync!( + m_fixture.Manager.SystemContext, + systemOperation.Machine.GetReady, + systemOperation.State.NodeId, + CancellationToken.None).AsTask(); + await handlerEntered.Task.ConfigureAwait(false); + + // Without an interlock this second call enters the handler and + // parks on releaseHandler too, so the timeout is what turns the + // regression into a failure rather than a hang. + GetReadyMethodStateResult concurrent = await systemOperation.Machine.GetReady!.OnCallAsync!( + m_fixture.Manager.SystemContext, + systemOperation.Machine.GetReady, + systemOperation.State.NodeId, + CancellationToken.None) + .AsTask() + .WaitAsync(TimeSpan.FromSeconds(10)) + .ConfigureAwait(false); + + releaseHandler.TrySetResult(true); + GetReadyMethodStateResult first = await getReady.ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That( + concurrent.ServiceResult.StatusCode, + Is.EqualTo(StatusCodes.BadInvalidState), + "a second transition must be rejected while one is in flight"); + Assert.That(ServiceResult.IsGood(first.ServiceResult), Is.True); + Assert.That( + systemOperation.Machine.CurrentState!.Value.Text, + Is.EqualTo("Ready")); + }); + } + + private static ValueTask GoodOperation( + RoboticsOperationContext context, + CancellationToken cancellationToken) + { + return new ValueTask(ServiceResult.Good); + } + + private static ValueTask GoodStop( + RoboticsStopRequest request, + CancellationToken cancellationToken) + { + return new ValueTask(ServiceResult.Good); + } + + private string NextName(string prefix) + { + return $"{prefix}{++m_nameCounter}"; + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/RoboticsOperationsConventionBuilderTests.cs b/tests/Opc.Ua.Robotics.Tests/RoboticsOperationsConventionBuilderTests.cs new file mode 100644 index 0000000000..63ea6539e6 --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/RoboticsOperationsConventionBuilderTests.cs @@ -0,0 +1,194 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.Robotics; +using Opc.Ua.Robotics.Operations; +using Opc.Ua.Robotics.Server; +using Opc.Ua.Robotics.Server.Builders; + +namespace Opc.Ua.Robotics.Server.Tests +{ + [TestFixture] + [NonParallelizable] + [Category("Robotics")] + public sealed class RoboticsOperationsConventionBuilderTests + { + private RoboticsServerFixture m_fixture = null!; + private int m_nameCounter; + + [OneTimeSetUp] + public async Task SetUpAsync() + { + m_fixture = new RoboticsServerFixture(); + await m_fixture.StartAsync().ConfigureAwait(false); + } + + [OneTimeTearDown] + public async Task TearDownAsync() + { + await m_fixture.DisposeAsync().ConfigureAwait(false); + } + + [Test] + public async Task RegisteredHandlersMaterializeOnlySelectedMethodsWithMetadata() + { + IRoboticsOperationsBuilder operations = null!; + + await BuildRobotAsync(builder => + { + operations = builder.AddOperations("Operations", builder.BuildContext.InstanceNamespaceIndex, op => op + .OnMoveTo((_, _) => new ValueTask(RoboticsOperationResult.Good)) + .OnGrasp((_, _) => new ValueTask(RoboticsOperationResult.Good))); + }).ConfigureAwait(false); + + Assert.That(operations.State, Is.Not.Null); + Assert.That(FindMethod(operations.State!, "MoveTo"), Is.Not.Null); + Assert.That(FindMethod(operations.State!, "Grasp"), Is.Not.Null); + Assert.That(FindMethod(operations.State!, "MoveJ"), Is.Null); + MethodState moveTo = FindMethod(operations.State!, "MoveTo")!; + Assert.That(moveTo.InputArguments, Is.Not.Null); + Assert.That(moveTo.OutputArguments, Is.Not.Null); + Assert.That(moveTo.InputArguments!.Value[0].Name, Is.EqualTo("TargetFrame")); + Assert.That(moveTo.OutputArguments!.Value[0].Name, Is.EqualTo("StatusCode")); + } + + [Test] + public void StandardNamespaceIndexIsRejected() + { + IRoboticsBuildContext context = m_fixture.CreateBuildContext(); + ushort roboticsNamespaceIndex = + (ushort)context.Context.NamespaceUris.GetIndex(Opc.Ua.Robotics.Namespaces.Robotics); + + ServiceResultException exception = Assert.ThrowsAsync(async () => + await context.AddMotionDeviceSystemAsync(NextName("Cell"), system => + { + AddRequiredTopology(system, motion => + motion.AddOperations("Operations", roboticsNamespaceIndex, _ => { })); + }).ConfigureAwait(false))!; + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + } + + [Test] + public async Task MoveToReturnsHandlerResultAndFailureStatus() + { + IRoboticsOperationsBuilder operations = null!; + + await BuildRobotAsync(builder => + { + operations = builder.AddOperations("Operations", builder.BuildContext.InstanceNamespaceIndex, op => op + .OnMoveTo((_, _) => new ValueTask( + new RoboticsOperationResult(new ServiceResult(StatusCodes.BadInvalidArgument), "no")))); + }).ConfigureAwait(false); + + MethodState moveTo = FindMethod(operations.State!, "MoveTo")!; + var outputs = ResultOutputs(); + ServiceResult result = await moveTo.OnCallMethod2Async!( + m_fixture.Manager.SystemContext, + moveTo, + operations.State!.NodeId, + [Structure(new ThreeDFrame()), Variant.Null, Variant.Null, Variant.Null], + outputs, + CancellationToken.None).ConfigureAwait(false); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That(outputs[1].TryGetValue(out string message), Is.True); + Assert.That(message, Is.EqualTo("no")); + } + + private async Task BuildRobotAsync(Action configureMotion) + { + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("Cell"), system => AddRequiredTopology(system, configureMotion)) + .ConfigureAwait(false); + } + + private static void AddRequiredTopology( + IMotionDeviceSystemBuilder system, + Action configureMotion) + { + system.AddMotionDevice("Robot", motion => + { + motion.WithCategory(MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT) + .WithSpeedOverride(50); + motion.AddAxis("Axis1", axis => axis + .AsVirtual() + .WithMotionProfile(AxisMotionProfileEnumeration.ROTARY) + .WithActualPosition(0)); + motion.AddPowerTrain("PowerTrain1", powerTrain => + powerTrain.AddMotor("Motor1", motor => motor.WithMotorTemperature(20))); + configureMotion(motion); + }); + system.AddSafetyState("Safety"); + system.AddController("Controller", controller => + { + controller.WithCurrentUser(user => user.WithLevel("Operator").WithName("alice")); + controller.AddSoftware("Software"); + controller.AddTaskControl("Task", task => task + .WithComponentName("Task") + .WithTaskProgramLoaded(false) + .WithTaskProgramName(string.Empty)); + }); + } + + private static MethodState? FindMethod(BaseObjectState operations, string name) + { + var children = new List(); + operations.GetChildren(null!, children); + for (int ii = 0; ii < children.Count; ii++) + { + if (children[ii] is MethodState method && method.BrowseName.Name == name) + { + return method; + } + } + return null; + } + + private static List ResultOutputs() + { + return [Variant.Null, Variant.Null, Variant.Null]; + } + + private static Variant Structure(IEncodeable value) + { + return new Variant(new ExtensionObject(value)); + } + + private string NextName(string prefix) + { + return $"{prefix}{++m_nameCounter}"; + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/RoboticsServerFixture.cs b/tests/Opc.Ua.Robotics.Tests/RoboticsServerFixture.cs new file mode 100644 index 0000000000..6b0c5df7bd --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/RoboticsServerFixture.cs @@ -0,0 +1,86 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Tasks; +using Opc.Ua.Robotics.Server; +using Opc.Ua.Server; +using Opc.Ua.Server.TestFramework; + +namespace Opc.Ua.Robotics.Server.Tests +{ + /// + /// Boots the stock Robotics node manager and exposes a direct build context. + /// + internal sealed class RoboticsServerFixture : IAsyncDisposable + { + private ServerFixture? m_fixture; + + public StandardServer Server { get; private set; } = null!; + + public ApplicationConfiguration Configuration => + m_fixture?.Config ?? + throw new InvalidOperationException("The Robotics server fixture is not started."); + + public RoboticsNodeManager Manager { get; private set; } = null!; + + public IRoboticsBuildContext CreateBuildContext() + { + return Manager.CreateRoboticsBuildContext(); + } + + public async Task StartAsync() + { + m_fixture = new ServerFixture( + telemetry => new StandardServer(telemetry)) + { + AutoAccept = true, + SecurityNone = true + }; + Server = await m_fixture.StartAsync().ConfigureAwait(false); + + Manager = new RoboticsNodeManager( + Server.CurrentInstance, + m_fixture.Config); + var externalReferences = new Dictionary>(); + await Manager.CreateAddressSpaceAsync(externalReferences) + .ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + Manager?.Dispose(); + if (m_fixture != null) + { + await m_fixture.StopAsync().ConfigureAwait(false); + } + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/RoboticsServerHostingTests.cs b/tests/Opc.Ua.Robotics.Tests/RoboticsServerHostingTests.cs new file mode 100644 index 0000000000..7a7b751421 --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/RoboticsServerHostingTests.cs @@ -0,0 +1,344 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.Di.Server; +using Opc.Ua.Di.Server.Hosting; +using Opc.Ua.Robotics.Server; +using Opc.Ua.Server.Fluent; +using Opc.Ua.Server.Hosting; + +namespace Opc.Ua.Robotics.Server.Tests +{ + [TestFixture] + [Category("Robotics")] + [Category("Hosting")] + public sealed class RoboticsServerHostingTests + { + [Test] + public void AddOpcUaDiThenAddRoboticsThrows() + { + IServiceCollection services = new ServiceCollection(); + IOpcUaServerBuilder builder = services.AddOpcUa() + .AddServer(options => options.ApplicationName = "test") + .AddOpcUaDi(); + + InvalidOperationException exception = Assert.Throws( + () => builder.AddRobotics())!; + + Assert.That(exception.Message, Does.Contain(nameof(OpcUaServerDiBuilderExtensions.AddOpcUaDi))); + } + + [Test] + public void AddRoboticsThenAddOpcUaDiThrows() + { + IServiceCollection services = new ServiceCollection(); + IOpcUaServerBuilder builder = services.AddOpcUa() + .AddServer(options => options.ApplicationName = "test") + .AddRobotics(); + + InvalidOperationException exception = Assert.Throws( + () => builder.AddOpcUaDi())!; + + Assert.That(exception.Message, Does.Contain(nameof(OpcUaServerRoboticsBuilderExtensions.AddRobotics))); + } + + [Test] + public void AddRoboticsRegistersDiAddressSpaceOwnership() + { + IServiceCollection services = new ServiceCollection(); + services.AddOpcUa() + .AddServer(options => options.ApplicationName = "test") + .AddRobotics(); + + using ServiceProvider provider = services.BuildServiceProvider(); + DiAddressSpaceOwnership ownership = + provider.GetRequiredService(); + + Assert.That( + ownership.OwnerName, + Is.EqualTo(nameof(OpcUaServerRoboticsBuilderExtensions.AddRobotics))); + } + + [Test] + public async Task ConfigureRoboticsAsyncLambdaReturnsAwaitableValueTask() + { + IServiceCollection services = new ServiceCollection(); + var completion = new TaskCompletionSource(); + IOpcUaServerBuilder builder = services.AddOpcUa() + .AddServer(options => options.ApplicationName = "test"); + + builder.ConfigureRobotics(async context => + { + _ = context; + await completion.Task.ConfigureAwait(false); + }); + + ServiceDescriptor registration = services.Single(descriptor => + descriptor.ServiceType.Name == "IRoboticsConfigurationRegistration"); + object instance = registration.ImplementationInstance!; + MethodInfo configureAsync = instance.GetType().GetMethod("ConfigureAsync")!; + var context = new FakeRoboticsBuildContext(); + + var task = (ValueTask)configureAsync.Invoke(instance, new object[] { context })!; + + Assert.That(task.IsCompleted, Is.False); + completion.SetResult(true); + await task.ConfigureAwait(false); + } + + [Test] + public async Task ConfigureRoboticsForAsyncLambdaReturnsAwaitableValueTask() + { + IServiceCollection services = new ServiceCollection(); + var completion = new TaskCompletionSource(); + IOpcUaServerBuilder builder = services.AddOpcUa() + .AddServer(options => options.ApplicationName = "test"); + + builder.ConfigureRoboticsFor(async context => + { + _ = context; + await completion.Task.ConfigureAwait(false); + }); + + ServiceDescriptor registration = services.Single(descriptor => + descriptor.ServiceType.Name == "IRoboticsConfigurationRegistration"); + object instance = registration.ImplementationInstance!; + MethodInfo configureAsync = instance.GetType().GetMethod("ConfigureAsync")!; + var context = new FakeRoboticsBuildContext(); + + var task = (ValueTask)configureAsync.Invoke(instance, new object[] { context })!; + + Assert.That(task.IsCompleted, Is.False); + completion.SetResult(true); + await task.ConfigureAwait(false); + } + + [Test] + public void EmptyProviderListThrowsConfigurationError() + { + ServiceResultException exception = Assert.Throws( + () => new RoboticsNodeManagerFactory( + ArrayOf.Empty, + new RoboticsServerOptions()))!; + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(exception.Message, Does.Contain("At least one Robotics model provider")); + } + + [Test] + public void CustomCoreProviderCanReplaceBuiltInProvider() + { + var factory = new RoboticsNodeManagerFactory( + new IRoboticsModelProvider[] { new CustomCoreProvider() }, + new RoboticsServerOptions()); + + string[] namespaceUris = factory.NamespacesUris.Memory.ToArray(); + Assert.That( + namespaceUris, + Does.Contain(Opc.Ua.IA.Namespaces.IA) + .And.Contain(Opc.Ua.Robotics.Namespaces.Robotics)); + } + + [Test] + public void ProviderMissingCoreNamespaceThrowsConfigurationError() + { + ServiceResultException exception = Assert.Throws( + () => new RoboticsNodeManagerFactory( + new IRoboticsModelProvider[] { new MissingCoreProvider() }, + new RoboticsServerOptions()))!; + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(exception.Message, Does.Contain("collectively advertise")); + } + + [Test] + public void BuiltInProviderUsesEarliestOrder() + { + Assert.That(new RoboticsModelProvider().Order, Is.EqualTo(int.MinValue)); + } + + [Test] + public void DefaultInstanceNamespaceIsStableProjectNamespace() + { + Assert.That( + RoboticsServerOptions.DefaultInstanceNamespaceUri, + Is.EqualTo("urn:opcua-netstandard:robotics:instances")); + Assert.DoesNotThrow(() => new RoboticsServerOptions().Validate()); + } + + [Test] + public void StandardModelNamespacesCannotBeInstanceNamespace() + { + string[] reservedNamespaces = + [ + global::Opc.Ua.Namespaces.OpcUa, + DiNodeManager.DiNamespaceUri, + Opc.Ua.IA.Namespaces.IA, + Robotics.Namespaces.Robotics + ]; + + for (int ii = 0; ii < reservedNamespaces.Length; ii++) + { + var options = new RoboticsServerOptions + { + InstanceNamespaceUri = reservedNamespaces[ii] + }; + ServiceResultException exception = Assert.Throws( + () => new RoboticsNodeManagerFactory( + new IRoboticsModelProvider[] + { + new RoboticsModelProvider() + }, + options))!; + + Assert.That( + exception.StatusCode, + Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(exception.Message, Does.Contain(reservedNamespaces[ii])); + Assert.That(exception.Message, Does.Contain("application-owned")); + } + } + + [Test] + public void ProviderNamespaceCannotBeInstanceNamespace() + { + var options = new RoboticsServerOptions + { + InstanceNamespaceUri = CustomModelProvider.ModelNamespaceUri + }; + + ServiceResultException exception = Assert.Throws( + () => new RoboticsNodeManagerFactory( + new IRoboticsModelProvider[] { new CustomModelProvider() }, + options))!; + + Assert.That( + exception.StatusCode, + Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That( + exception.Message, + Does.Contain(CustomModelProvider.ModelNamespaceUri)); + Assert.That(exception.Message, Does.Contain(nameof(CustomModelProvider))); + } + + [Test] + public void BuiltInProviderGuardsNullArguments() + { + var provider = new RoboticsModelProvider(); + + Assert.That( + Assert.Throws( + () => provider.AddPredefinedNodes(null!, null!))!.ParamName, + Is.EqualTo("nodes")); + Assert.That( + Assert.Throws( + () => provider.AddPredefinedNodes(new NodeStateCollection(), null!))!.ParamName, + Is.EqualTo("context")); + } + + private sealed class CustomCoreProvider : IRoboticsModelProvider + { + public int Order => 0; + + public ArrayOf NamespaceUris => new string[] + { + Opc.Ua.IA.Namespaces.IA, + Opc.Ua.Robotics.Namespaces.Robotics + }; + + public void AddPredefinedNodes(NodeStateCollection nodes, ISystemContext context) + { + } + } + + private sealed class MissingCoreProvider : IRoboticsModelProvider + { + public int Order => 0; + + public ArrayOf NamespaceUris => new string[] + { + Opc.Ua.IA.Namespaces.IA + }; + + public void AddPredefinedNodes(NodeStateCollection nodes, ISystemContext context) + { + } + } + + private sealed class CustomModelProvider : IRoboticsModelProvider + { + public const string ModelNamespaceUri = "urn:tests:robotics:custom-model"; + + public int Order => 0; + + public ArrayOf NamespaceUris => new string[] + { + Opc.Ua.IA.Namespaces.IA, + Robotics.Namespaces.Robotics, + ModelNamespaceUri + }; + + public void AddPredefinedNodes(NodeStateCollection nodes, ISystemContext context) + { + } + } + + private sealed class FakeRoboticsBuildContext : IRoboticsBuildContext + { + public DiNodeManager Manager => throw new NotSupportedException(); + + public ISystemContext Context => throw new NotSupportedException(); + + public INodeManagerBuilder Nodes => throw new NotSupportedException(); + + public ushort InstanceNamespaceIndex => throw new NotSupportedException(); + + public NodeState DeviceSet => throw new NotSupportedException(); + + public CancellationToken CancellationToken => default; + + public T GetRequiredService() where T : notnull + { + throw new NotSupportedException(); + } + + public void Seal() + { + throw new NotSupportedException(); + } + } + } +} diff --git a/tests/Opc.Ua.Robotics.Tests/RoboticsTopologyBuilderTests.cs b/tests/Opc.Ua.Robotics.Tests/RoboticsTopologyBuilderTests.cs new file mode 100644 index 0000000000..651a040379 --- /dev/null +++ b/tests/Opc.Ua.Robotics.Tests/RoboticsTopologyBuilderTests.cs @@ -0,0 +1,2280 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.Di; +using Opc.Ua.Di.Server; +using Opc.Ua.Di.Server.Hosting; +using Opc.Ua.Robotics; +using Opc.Ua.Robotics.Server; +using Opc.Ua.Robotics.Server.Builders; +using Opc.Ua.Server; +using Opc.Ua.Server.Fluent; + +namespace Opc.Ua.Robotics.Server.Tests +{ + [TestFixture] + [NonParallelizable] + [Category("Robotics")] + [Category("TopologyBuilder")] + public sealed class RoboticsTopologyBuilderTests + { + private RoboticsServerFixture m_fixture = null!; + private int m_nameCounter; + + [OneTimeSetUp] + public async Task SetUpAsync() + { + m_fixture = new RoboticsServerFixture(); + await m_fixture.StartAsync().ConfigureAwait(false); + } + + [OneTimeTearDown] + public async Task TearDownAsync() + { + await m_fixture.DisposeAsync().ConfigureAwait(false); + } + + [Test] + public async Task FullGraphRegistersTypedTopologyAndSemantics() + { + var graph = new FullGraph(); + DateTimeUtc telemetryTimestamp = DateTimeUtc.Now; + var degrees = new EUInformation + { + NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact", + UnitId = 17476, + DisplayName = new LocalizedText("deg") + }; + var range = new global::Opc.Ua.Range + { + Low = -180, + High = 180 + }; + + graph.System = await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("Cell"), system => + { + system.WithComponentName("Robot Cell"); + graph.MotionDevice = system.AddMotionDevice("Robot", motion => + { + motion.WithIdentification(data => + { + data.Manufacturer = new LocalizedText("Acme Robotics"); + data.Model = new LocalizedText("R-100"); + data.ProductCode = "R100"; + data.SerialNumber = "ROBOT-1"; + }) + .WithCategory( + MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT) + .WithComponentName("Robot") + .WithSpeedOverride( + 75, + StatusCodes.GoodClamped, + telemetryTimestamp) + .WithFlangeLoad(load => load + .WithMass(12.5, engineeringUnits: degrees, range: range) + .WithCenterOfMass(new ThreeDFrame()) + .WithInertia(new ThreeDVector())); + graph.Axis = motion.AddAxis("Axis1", axis => axis + .WithMotionProfile(AxisMotionProfileEnumeration.ROTARY) + .WithActualPosition( + 12, + degrees, + range, + StatusCodes.Good, + telemetryTimestamp) + .WithActualSpeed(3, degrees) + .WithActualAcceleration(0.5, degrees) + .WithAdditionalLoad(load => load.WithMass(2))); + graph.PowerTrain = motion.AddPowerTrain( + "PowerTrain1", + powerTrain => + { + powerTrain.WithComponentName("Main train"); + graph.Motor = powerTrain.AddMotor("Motor1", motor => + motor.WithIdentification(data => + { + data.Manufacturer = + new LocalizedText("Motor Corp"); + data.ProductCode = "MOTOR"; + data.SerialNumber = "M-1"; + }) + .WithMotorTemperature(42, degrees) + .WithBrakeReleased(true) + .WithEffectiveLoadRate(25)); + graph.Gear = powerTrain.AddGear("Gear1", gear => gear + .WithIdentification(data => + { + data.Manufacturer = + new LocalizedText("Gear Corp"); + data.ProductCode = "GEAR"; + data.SerialNumber = "G-1"; + }) + .WithGearRatio(-100, 3) + .WithPitch(4.5)); + }); + graph.SlavePowerTrain = motion.AddPowerTrain( + "SlavePowerTrain", + powerTrain => + { + graph.SlaveMotor = powerTrain.AddMotor( + "SlaveMotor", + motor => motor.WithMotorTemperature(38)); + }); + motion.AddAuxiliaryComponent("RobotIo", component => component + .WithProductCode("IO") + .WithAssetId("asset-io") + .WithComponentName("Robot IO")); + }); + graph.Safety = system.AddSafetyState("Safety", safety => + { + safety.WithComponentName("Safety") + .WithEmergencyStop(false) + .WithOperationalMode(OperationalModeEnumeration.AUTOMATIC) + .WithProtectiveStop(true); + graph.EmergencyStop = safety.AddEmergencyStop( + "EmergencyStop1", + "Main E-Stop", + stop => stop.WithActive(false)); + graph.ProtectiveStop = safety.AddProtectiveStop( + "ProtectiveStop1", + "Guard Door", + stop => stop.WithEnabled(true).WithActive(true)); + }); + graph.Controller = system.AddController("Controller", controller => + { + controller.WithIdentification(data => + { + data.Manufacturer = new LocalizedText("Acme Controls"); + data.ProductCode = "CTRL"; + data.SerialNumber = "C-1"; + }) + .WithComponentName("Controller"); + graph.Software = controller.AddSoftware( + "ControlSoftware", + software => software + .WithIdentification(data => + { + data.Manufacturer = + new LocalizedText("Acme Controls"); + data.Model = new LocalizedText("Robot Runtime"); + data.SoftwareRevision = "2.0"; + }) + .Configure((state, _) => + state.DisplayName = + new LocalizedText("Control Software"))); + graph.Drive = controller.AddDrive("Drive1", drive => drive + .WithProductCode("DRV") + .WithAssetId("asset-drive") + .WithComponentName("Axis drive")); + controller.AddAuxiliaryComponent("ControllerIo", component => + component.WithProductCode("CIO")); + graph.TaskControl = controller.AddTaskControl( + "Task1", + taskControl => + { + taskControl.WithComponentName("Task") + .WithExecutionMode( + ExecutionModeEnumeration.CONTINUOUS) + .WithTaskProgramLoaded(true) + .WithTaskProgramName("Weld"); + graph.TaskModule = taskControl.AddTaskModule( + "Module1", + module => module + .WithName("Main") + .WithVersion("1.0") + .WithIsReferenced(true)); + }); + }); + + graph.Controller.Controls(graph.MotionDevice) + .UsesSafetyState(graph.Safety) + .IsConnectedTo(graph.MotionDevice); + graph.TaskControl.Controls(graph.MotionDevice); + graph.MotionDevice.UsesTaskControl(graph.TaskControl); + graph.Axis.Requires(graph.PowerTrain).IsConnectedTo(graph.Gear); + graph.PowerTrain.Moves(graph.Axis) + .HasSlave(graph.SlavePowerTrain) + .IsConnectedTo(graph.Gear); + graph.Motor.IsDrivenBy(graph.Drive).IsConnectedTo(graph.Gear); + graph.SlaveMotor.IsDrivenBy(graph.Drive); + graph.Drive.IsConnectedTo(graph.Gear); + }) + .ConfigureAwait(false); + + Assert.That(graph.System.State, Is.TypeOf()); + Assert.That(graph.Controller.State, Is.TypeOf()); + Assert.That(graph.MotionDevice.State, Is.TypeOf()); + Assert.That(graph.Axis.State, Is.TypeOf()); + Assert.That(graph.PowerTrain.State, Is.TypeOf()); + Assert.That(graph.Motor.State, Is.TypeOf()); + Assert.That(graph.Gear.State, Is.TypeOf()); + Assert.That(graph.Drive.State, Is.TypeOf()); + Assert.That(graph.Software.State, Is.TypeOf()); + Assert.That(graph.Safety.State, Is.TypeOf()); + + Assert.That(graph.System.State.Controllers, Is.Not.Null); + Assert.That(graph.System.State.MotionDevices, Is.Not.Null); + Assert.That(graph.System.State.SafetyStates, Is.Not.Null); + Assert.That(graph.MotionDevice.State.Axes, Is.Not.Null); + Assert.That(graph.MotionDevice.State.PowerTrains, Is.Not.Null); + Assert.That(graph.Controller.State.TaskControls, Is.Not.Null); + Assert.That(graph.Controller.State.Software, Is.Not.Null); + Assert.That(graph.Controller.State.Components, Is.Not.Null); + + Assert.That(graph.System.State.Parent, Is.SameAs( + graph.System.BuildContext.DeviceSet)); + Assert.That(graph.Controller.State.Parent, Is.SameAs( + graph.System.State.Controllers)); + Assert.That(graph.MotionDevice.State.Parent, Is.SameAs( + graph.System.State.MotionDevices)); + Assert.That(graph.Safety.State.Parent, Is.SameAs( + graph.System.State.SafetyStates)); + Assert.That(graph.Axis.State.Parent, Is.SameAs( + graph.MotionDevice.State.Axes)); + Assert.That(graph.PowerTrain.State.Parent, Is.SameAs( + graph.MotionDevice.State.PowerTrains)); + Assert.That(graph.Motor.State.Parent, Is.SameAs(graph.PowerTrain.State)); + Assert.That(graph.Gear.State.Parent, Is.SameAs(graph.PowerTrain.State)); + Assert.That(graph.Drive.State.Parent, Is.SameAs( + graph.Controller.State.Components)); + Assert.That(graph.Software.State.Parent, Is.SameAs( + graph.Controller.State.Software)); + Assert.That(graph.TaskModule.State.Parent, Is.SameAs( + graph.TaskControl.State.TaskModules)); + Assert.That( + graph.TaskModule.State.ReferenceTypeId, + Is.EqualTo(global::Opc.Ua.ReferenceTypeIds.Organizes)); + Assert.That(graph.Motor.State.Parent, Is.Not.SameAs(graph.Drive.State)); + + AssertTreeRegisteredAndUnique(graph.System.State); + ushort roboticsNamespaceIndex = (ushort)m_fixture.Manager.SystemContext + .NamespaceUris.GetIndex(Robotics.Namespaces.Robotics); + Assert.That( + graph.System.State.NodeId.NamespaceIndex, + Is.EqualTo(m_fixture.Manager.InstanceNamespaceIndex)); + Assert.That( + graph.System.State.BrowseName.NamespaceIndex, + Is.EqualTo(m_fixture.Manager.InstanceNamespaceIndex)); + Assert.That( + graph.Controller.State.BrowseName.NamespaceIndex, + Is.EqualTo(m_fixture.Manager.InstanceNamespaceIndex)); + Assert.That( + graph.System.State.Controllers!.BrowseName.NamespaceIndex, + Is.EqualTo(roboticsNamespaceIndex)); + + AssertReference( + graph.Controller.State, + Robotics.ReferenceTypes.Controls, + graph.MotionDevice.State); + AssertReference( + graph.TaskControl.State, + Robotics.ReferenceTypes.Controls, + graph.MotionDevice.State); + AssertReference( + graph.Controller.State, + Robotics.ReferenceTypes.HasSafetyStates, + graph.Safety.State); + AssertReference( + graph.PowerTrain.State, + Robotics.ReferenceTypes.Moves, + graph.Axis.State); + AssertReference( + graph.Axis.State, + Robotics.ReferenceTypes.Requires, + graph.PowerTrain.State); + AssertReference( + graph.Motor.State, + Robotics.ReferenceTypes.IsDrivenBy, + graph.Drive.State); + AssertReference( + graph.PowerTrain.State, + Robotics.ReferenceTypes.HasSlave, + graph.SlavePowerTrain.State); + AssertReference( + graph.Axis.State, + Robotics.ReferenceTypes.IsConnectedTo, + graph.Gear.State); + + Assert.That( + graph.MotionDevice.State.Manufacturer!.Value.Text, + Is.EqualTo("Acme Robotics")); + Assert.That( + graph.MotionDevice.State.MotionDeviceCategory!.Value, + Is.EqualTo(MotionDeviceCategoryEnumeration.ARTICULATED_ROBOT)); + Assert.That( + FindChild>( + graph.MotionDevice.State.ParameterSet!, + "SpeedOverride").Value, + Is.EqualTo(75)); + Assert.That( + graph.Axis.State.MotionProfile!.Value, + Is.EqualTo(AxisMotionProfileEnumeration.ROTARY)); + Assert.That( + FindChild>( + graph.Axis.State.ParameterSet!, + "ActualPosition").Value, + Is.EqualTo(12)); + Assert.That( + FindChild>( + graph.Axis.State.ParameterSet!, + "ActualSpeed").Value, + Is.EqualTo(3)); + Assert.That( + graph.Gear.State.GearRatio!.Value.Denominator, + Is.EqualTo(3)); + Assert.That( + graph.Gear.State.GearRatio.Value.Numerator, + Is.EqualTo(-100)); + Assert.That(graph.Gear.State.Pitch!.Value, Is.EqualTo(4.5)); + var pitchChildren = new List(); + graph.Gear.State.Pitch.GetChildren( + m_fixture.Manager.SystemContext, + pitchChildren); + Assert.That(pitchChildren, Is.Empty); + Assert.That(graph.Drive.State.AssetId!.Value, Is.EqualTo("asset-drive")); + Assert.That( + graph.Software.State.SoftwareRevision!.Value, + Is.EqualTo("2.0")); + Assert.That(graph.EmergencyStop.State.Name!.Value, Is.EqualTo("Main E-Stop")); + Assert.That(graph.ProtectiveStop.State.Enabled!.Value, Is.True); + Assert.That(graph.TaskModule.State.Version!.Value, Is.EqualTo("1.0")); + Assert.That(graph.MotionDevice.State.TaskControlReference, Is.Null); + + INodeBuilder rootNode = graph.System.AsNode(); + Assert.That(rootNode.Node, Is.SameAs(graph.System.State)); + Assert.That( + rootNode.Components().Controllers().Node, + Is.SameAs(graph.System.State.Controllers)); + Assert.That( + graph.MotionDevice.AsNode().Components().MotionDeviceCategory().Node, + Is.SameAs(graph.MotionDevice.State.MotionDeviceCategory)); + } + + [Test] + public async Task MultipleSystemsHaveDisjointNodeIds() + { + IRoboticsBuildContext context = m_fixture.CreateBuildContext(); + GraphParts first = await AddValidGraphAsync(context, NextName("Multi")) + .ConfigureAwait(false); + GraphParts second = await AddValidGraphAsync(context, NextName("Multi")) + .ConfigureAwait(false); + + HashSet firstIds = GetNodeIds(first.System.State); + HashSet secondIds = GetNodeIds(second.System.State); + firstIds.IntersectWith(secondIds); + + Assert.That(firstIds, Is.Empty); + } + + [Test] + public async Task ConcurrentContextsReserveDisjointNodeIds() + { + var manager = new CustomRoboticsNodeManager( + m_fixture.Server.CurrentInstance, + m_fixture.Configuration); + try + { + var externalReferences = new Dictionary>(); + await manager.CreateAddressSpaceAsync(externalReferences) + .ConfigureAwait(false); + + var options = new RoboticsServerOptions + { + InstanceNamespaceUri = + CustomRoboticsNodeManager.InstanceNamespaceUri + }; + IRoboticsBuildContext firstContext = + manager.CreateRoboticsBuildContext(options); + IRoboticsBuildContext secondContext = + manager.CreateRoboticsBuildContext(options); + manager.PauseNextRootRegistrations(2); + + Task firstTask = firstContext + .AddMotionDeviceSystemAsync( + "ConcurrentCell1", + system => ConfigureValidGraph(system)) + .AsTask(); + Task secondTask = secondContext + .AddMotionDeviceSystemAsync( + "ConcurrentCell2", + system => ConfigureValidGraph(system)) + .AsTask(); + + try + { + await manager.WaitForPausedRootRegistrationsAsync() + .WaitAsync(TimeSpan.FromSeconds(30)) + .ConfigureAwait(false); + } + finally + { + manager.ResumeRootRegistrations(); + } + + IMotionDeviceSystemBuilder[] systems = + await Task.WhenAll(firstTask, secondTask).ConfigureAwait(false); + HashSet firstIds = AssertTreeRegisteredAndUnique( + manager, + systems[0].State, + manager.InstanceNamespaceIndex); + HashSet secondIds = AssertTreeRegisteredAndUnique( + manager, + systems[1].State, + manager.InstanceNamespaceIndex); + firstIds.IntersectWith(secondIds); + + Assert.That(firstIds, Is.Empty); + List deviceSetChildren = + GetChildren(firstContext.Context, firstContext.DeviceSet); + Assert.That(deviceSetChildren, Does.Contain(systems[0].State)); + Assert.That(deviceSetChildren, Does.Contain(systems[1].State)); + } + finally + { + manager.Dispose(); + } + } + + [Test] + public async Task ConcurrentContextsRejectDuplicateRootBrowseName() + { + string browseName = NextName("ConcurrentDuplicate"); + IRoboticsBuildContext firstContext = m_fixture.CreateBuildContext(); + IRoboticsBuildContext secondContext = m_fixture.CreateBuildContext(); + using var firstConfigurationStarted = new ManualResetEventSlim(); + using var releaseFirstConfiguration = new ManualResetEventSlim(); + + Task firstTask = Task.Run(async () => + await firstContext.AddMotionDeviceSystemAsync( + browseName, + system => + { + firstConfigurationStarted.Set(); + if (!releaseFirstConfiguration.Wait(TimeSpan.FromSeconds(30))) + { + throw new TimeoutException( + "The duplicate root BrowseName test was not released."); + } + ConfigureValidGraph(system); + }) + .ConfigureAwait(false)); + + if (!firstConfigurationStarted.Wait(TimeSpan.FromSeconds(30))) + { + releaseFirstConfiguration.Set(); + await firstTask.ConfigureAwait(false); + Assert.Fail("The first duplicate root configuration did not start."); + } + + IMotionDeviceSystemBuilder? secondSystem = null; + ServiceResultException? duplicateException = null; + try + { + try + { + secondSystem = await secondContext + .AddMotionDeviceSystemAsync( + browseName, + system => ConfigureValidGraph(system)) + .ConfigureAwait(false); + } + catch (ServiceResultException exception) + { + duplicateException = exception; + } + } + finally + { + releaseFirstConfiguration.Set(); + } + + IMotionDeviceSystemBuilder firstSystem = + await firstTask.ConfigureAwait(false); + _ = AssertTreeRegisteredAndUnique( + m_fixture.Manager, + firstSystem.State, + firstContext.InstanceNamespaceIndex); + + Assert.That(secondSystem, Is.Null); + Assert.That(duplicateException, Is.Not.Null); + Assert.That( + duplicateException!.StatusCode, + Is.EqualTo(StatusCodes.BadBrowseNameDuplicated)); + + List children = + GetChildren(firstContext.Context, firstContext.DeviceSet); + int matchingChildCount = 0; + for (int ii = 0; ii < children.Count; ii++) + { + if (children[ii].BrowseName == firstSystem.State.BrowseName) + { + matchingChildCount++; + } + } + + Assert.That(matchingChildCount, Is.EqualTo(1)); + Assert.That(children, Does.Contain(firstSystem.State)); + } + + [Test] + public async Task ConfigureRoboticsForCustomManagerUsesInstanceNamespace() + { + var manager = new CustomRoboticsNodeManager( + m_fixture.Server.CurrentInstance, + m_fixture.Configuration); + try + { + var externalReferences = new Dictionary>(); + await manager.CreateAddressSpaceAsync(externalReferences) + .ConfigureAwait(false); + + var systems = new List(); + IServiceCollection services = new ServiceCollection(); + services.AddOpcUa() + .AddServer(options => options.ApplicationName = "robotics-custom-manager") + .ConfigureRoboticsFor( + async context => + { + systems.Add(await context.AddMotionDeviceSystemAsync( + "CustomCell1", + system => ConfigureValidGraph(system)) + .ConfigureAwait(false)); + systems.Add(await context.AddMotionDeviceSystemAsync( + "CustomCell2", + system => ConfigureValidGraph(system)) + .ConfigureAwait(false)); + }); + services.Configure( + options => options.InstanceNamespaceUri = + CustomRoboticsNodeManager.InstanceNamespaceUri); + + using ServiceProvider provider = services.BuildServiceProvider(); + IDiPostSetupRunner runner = + provider.GetRequiredService(); + await runner.RunAsync(manager, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(systems, Has.Count.EqualTo(2)); + ushort instanceNamespaceIndex = manager.InstanceNamespaceIndex; + HashSet firstIds = AssertTreeRegisteredAndUnique( + manager, + systems[0].State, + instanceNamespaceIndex); + HashSet secondIds = AssertTreeRegisteredAndUnique( + manager, + systems[1].State, + instanceNamespaceIndex); + firstIds.IntersectWith(secondIds); + + Assert.That(firstIds, Is.Empty); + Assert.That( + systems[0].AsNode().Node, + Is.SameAs(systems[0].State)); + } + finally + { + manager.Dispose(); + } + } + + [Test] + public async Task CustomManagerWithoutRoboticsNodeIdFactoryIsRejected() + { + var manager = new UnmarkedRoboticsNodeManager( + m_fixture.Server.CurrentInstance, + m_fixture.Configuration); + try + { + var externalReferences = new Dictionary>(); + await manager.CreateAddressSpaceAsync(externalReferences) + .ConfigureAwait(false); + var options = new RoboticsServerOptions + { + InstanceNamespaceUri = + UnmarkedRoboticsNodeManager.InstanceNamespaceUri + }; + + ServiceResultException exception = Assert.Throws( + () => manager.CreateRoboticsBuildContext(options))!; + + Assert.That( + exception.StatusCode, + Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That( + exception.Message, + Does.Contain(nameof(IRoboticsNodeIdFactory))); + Assert.That( + exception.Message, + Does.Contain("ConfigureRoboticsFor/CreateRoboticsBuildContext")); + } + finally + { + manager.Dispose(); + } + } + + [Test] + public async Task NewSkipsExistingNumericNodeId() + { + await using var fixture = new RoboticsServerFixture(); + await fixture.StartAsync().ConfigureAwait(false); + + ushort namespaceIndex = fixture.Manager.InstanceNamespaceIndex; + var existing = new BaseObjectState(null) + { + NodeId = new NodeId(1u, namespaceIndex), + BrowseName = new QualifiedName("Existing", namespaceIndex), + DisplayName = new LocalizedText("Existing"), + TypeDefinitionId = global::Opc.Ua.ObjectTypeIds.BaseObjectType + }; + await fixture.Manager.AddPredefinedNodeAsync(existing) + .ConfigureAwait(false); + + NodeId allocated = fixture.Manager.New( + fixture.Manager.SystemContext, + new BaseObjectState(null)); + + Assert.That(allocated, Is.EqualTo(new NodeId(2u, namespaceIndex))); + } + + [Test] + public async Task BuildContextAllocatorSkipsExistingNumericNodeId() + { + await using var fixture = new RoboticsServerFixture(); + await fixture.StartAsync().ConfigureAwait(false); + + ushort namespaceIndex = fixture.Manager.InstanceNamespaceIndex; + var existing = new BaseObjectState(null) + { + NodeId = new NodeId(1u, namespaceIndex), + BrowseName = new QualifiedName("Existing", namespaceIndex), + DisplayName = new LocalizedText("Existing"), + TypeDefinitionId = global::Opc.Ua.ObjectTypeIds.BaseObjectType + }; + await fixture.Manager.AddPredefinedNodeAsync(existing) + .ConfigureAwait(false); + + IMotionDeviceSystemBuilder system = + await fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync( + "AllocatedAfterExisting", + item => ConfigureValidGraph(item)) + .ConfigureAwait(false); + + Assert.That(system.State.NodeId, Is.EqualTo(new NodeId(2u, namespaceIndex))); + } + + [Test] + public async Task StockNewUnindexedReservationIsNotReusedByTopologyBuild() + { + await using var fixture = new RoboticsServerFixture(); + await fixture.StartAsync().ConfigureAwait(false); + + ushort namespaceIndex = fixture.Manager.InstanceNamespaceIndex; + NodeId reserved = fixture.Manager.New( + fixture.Manager.SystemContext, + new BaseObjectState(null)); + + IMotionDeviceSystemBuilder system = + await fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync( + "AllocatedAfterUnindexedReservation", + item => ConfigureValidGraph(item)) + .ConfigureAwait(false); + + HashSet topologyNodeIds = AssertTreeRegisteredAndUnique( + fixture.Manager, + system.State, + namespaceIndex); + Assert.That(topologyNodeIds, Does.Not.Contain(reserved)); + Assert.That(fixture.Manager.FindPredefinedNode(reserved), Is.Null); + } + + [Test] + public async Task FailedBuildsReleaseStockReservations() + { + await using var fixture = new RoboticsServerFixture(); + await fixture.StartAsync().ConfigureAwait(false); + + IRoboticsBuildContext context = fixture.CreateBuildContext(); + const string browseName = "RetryAfterFailedBuild"; + for (int ii = 0; ii < 3; ii++) + { + _ = Assert.ThrowsAsync(async () => + await context.AddMotionDeviceSystemAsync( + browseName, + system => + { + ConfigureValidGraph(system); + throw new InvalidOperationException( + "Expected configuration failure."); + }) + .ConfigureAwait(false)); + Assert.That(fixture.Manager.ReservedNodeIdCount, Is.Zero); + + ServiceResultException validationException = + Assert.ThrowsAsync(async () => + await context.AddMotionDeviceSystemAsync( + browseName, + system => ConfigureValidGraph( + system, + new GraphOptions { IncludeController = false })) + .ConfigureAwait(false))!; + Assert.That( + validationException.StatusCode, + Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(fixture.Manager.ReservedNodeIdCount, Is.Zero); + + ServiceResultException nodeIdException = + Assert.ThrowsAsync(async () => + await context.AddMotionDeviceSystemAsync( + browseName, + system => + { + ConfigureValidGraph(system); + system.Configure((state, _) => + state.NodeId = NodeId.Null); + }) + .ConfigureAwait(false))!; + Assert.That( + nodeIdException.StatusCode, + Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(nodeIdException.Message, Does.Contain("null NodeId")); + Assert.That(fixture.Manager.ReservedNodeIdCount, Is.Zero); + } + + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + _ = Assert.ThrowsAsync(async () => + await context.AddMotionDeviceSystemAsync( + browseName, + system => ConfigureValidGraph(system), + cancellationSource.Token) + .ConfigureAwait(false)); + Assert.That(fixture.Manager.ReservedNodeIdCount, Is.Zero); + + IMotionDeviceSystemBuilder system = + await context.AddMotionDeviceSystemAsync( + browseName, + item => ConfigureValidGraph(item)) + .ConfigureAwait(false); + + _ = AssertTreeRegisteredAndUnique( + fixture.Manager, + system.State, + fixture.Manager.InstanceNamespaceIndex); + Assert.That(fixture.Manager.ReservedNodeIdCount, Is.Zero); + } + + [Test] + public async Task AsNodeBeforeRegistrationThrowsBadInvalidState() + { + ServiceResultException? observed = null; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("BeforeRegistration"), system => + { + observed = Assert.Throws( + () => system.AsNode()); + ConfigureValidGraph(system); + }) + .ConfigureAwait(false); + + Assert.That(observed, Is.Not.Null); + Assert.That(observed!.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + } + + [Test] + public async Task AsNodeAndGeneratedTraversalWorkAfterContextSeal() + { + IRoboticsBuildContext context = m_fixture.CreateBuildContext(); + var graph = new GraphParts(); + graph.System = await context.AddMotionDeviceSystemAsync( + NextName("Sealed"), + system => Copy(ConfigureValidGraph(system), graph)) + .ConfigureAwait(false); + + context.Seal(); + + Assert.That(graph.System.AsNode().Node, Is.SameAs(graph.System.State)); + Assert.That( + graph.System.AsNode().Components().Controllers().Node, + Is.SameAs(graph.System.State.Controllers)); + Assert.That( + graph.MotionDevice.AsNode().Components().MotionDeviceCategory().Node, + Is.SameAs(graph.MotionDevice.State.MotionDeviceCategory)); + Assert.That(graph.Software.AsNode().Node, Is.SameAs(graph.Software.State)); + } + + [Test] + public async Task SealDuringRegistrationLeavesContextUsable() + { + var manager = new CustomRoboticsNodeManager( + m_fixture.Server.CurrentInstance, + m_fixture.Configuration); + try + { + var externalReferences = new Dictionary>(); + await manager.CreateAddressSpaceAsync(externalReferences) + .ConfigureAwait(false); + + var options = new RoboticsServerOptions + { + InstanceNamespaceUri = + CustomRoboticsNodeManager.InstanceNamespaceUri + }; + IRoboticsBuildContext context = + manager.CreateRoboticsBuildContext(options); + int initialDeviceSetChildCount = + GetChildren(context.Context, context.DeviceSet).Count; + manager.PauseNextRootRegistrations(1); + + Task registration = context + .AddMotionDeviceSystemAsync( + "SealActiveCell", + system => ConfigureValidGraph(system)) + .AsTask(); + + try + { + await manager.WaitForPausedRootRegistrationsAsync() + .WaitAsync(TimeSpan.FromSeconds(30)) + .ConfigureAwait(false); + ServiceResultException exception = + Assert.Throws(() => context.Seal())!; + Assert.That( + exception.StatusCode, + Is.EqualTo(StatusCodes.BadInvalidState)); + } + finally + { + manager.ResumeRootRegistrations(); + } + + IMotionDeviceSystemBuilder first = + await registration.ConfigureAwait(false); + IMotionDeviceSystemBuilder second = + await context.AddMotionDeviceSystemAsync( + "AfterRejectedSealCell", + system => ConfigureValidGraph(system)) + .ConfigureAwait(false); + context.Seal(); + + _ = AssertTreeRegisteredAndUnique( + manager, + first.State, + manager.InstanceNamespaceIndex); + _ = AssertTreeRegisteredAndUnique( + manager, + second.State, + manager.InstanceNamespaceIndex); + List deviceSetChildren = + GetChildren(context.Context, context.DeviceSet); + Assert.That( + deviceSetChildren, + Has.Count.EqualTo(initialDeviceSetChildCount + 2)); + Assert.That(deviceSetChildren, Does.Contain(first.State)); + Assert.That(deviceSetChildren, Does.Contain(second.State)); + } + finally + { + manager.Dispose(); + } + } + + [Test] + public async Task SealedContextRejectsTopologyWithoutChangingAddressSpace() + { + var manager = new CustomRoboticsNodeManager( + m_fixture.Server.CurrentInstance, + m_fixture.Configuration); + try + { + var externalReferences = new Dictionary>(); + await manager.CreateAddressSpaceAsync(externalReferences) + .ConfigureAwait(false); + + var options = new RoboticsServerOptions + { + InstanceNamespaceUri = + CustomRoboticsNodeManager.InstanceNamespaceUri + }; + IRoboticsBuildContext context = + manager.CreateRoboticsBuildContext(options); + int predefinedNodeCount = manager.PredefinedNodeCount; + List deviceSetChildren = + GetChildren(context.Context, context.DeviceSet); + int configureCalls = 0; + + context.Seal(); + + ServiceResultException exception = + Assert.ThrowsAsync(async () => + await context.AddMotionDeviceSystemAsync( + "RejectedAfterSeal", + system => + { + configureCalls++; + ConfigureValidGraph(system); + }) + .ConfigureAwait(false))!; + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + Assert.That(configureCalls, Is.Zero); + Assert.That(manager.PredefinedNodeCount, Is.EqualTo(predefinedNodeCount)); + Assert.That( + GetChildren(context.Context, context.DeviceSet), + Is.EqualTo(deviceSetChildren)); + } + finally + { + manager.Dispose(); + } + } + + [Test] + public async Task DirectCustomManagerContextRequiresRoboticsModel() + { + var manager = new MissingRoboticsModelNodeManager( + m_fixture.Server.CurrentInstance, + m_fixture.Configuration); + try + { + var externalReferences = new Dictionary>(); + await manager.CreateAddressSpaceAsync(externalReferences) + .ConfigureAwait(false); + var options = new RoboticsServerOptions + { + InstanceNamespaceUri = + MissingRoboticsModelNodeManager.InstanceNamespaceUri + }; + + ServiceResultException exception = Assert.Throws( + () => manager.CreateRoboticsBuildContext(options))!; + + Assert.That( + exception.StatusCode, + Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(exception.Message, Does.Contain("MotionDeviceSystemType")); + } + finally + { + manager.Dispose(); + } + } + + [Test] + public async Task MissingControllerFailsValidation() + { + await AssertConfigurationFailsAsync( + "at least one controller", + system => ConfigureValidGraph( + system, + new GraphOptions { IncludeController = false })) + .ConfigureAwait(false); + } + + [Test] + public async Task MissingMotionDeviceFailsValidation() + { + await AssertConfigurationFailsAsync( + "at least one motion device", + system => ConfigureValidGraph( + system, + new GraphOptions { IncludeMotionDevice = false })) + .ConfigureAwait(false); + } + + [Test] + public async Task MissingSafetyStateFailsValidation() + { + await AssertConfigurationFailsAsync( + "at least one safety state", + system => ConfigureValidGraph( + system, + new GraphOptions { IncludeSafetyState = false })) + .ConfigureAwait(false); + } + + [Test] + public async Task ControllerWithoutControlsSucceeds() + { + GraphParts graph = await AddValidGraphAsync( + NextName("NoControllerControls"), + new GraphOptions { AddControllerControls = false }) + .ConfigureAwait(false); + + Assert.That(graph.System.State.NodeId.IsNull, Is.False); + } + + [Test] + public async Task ControllerWithoutSoftwareFailsValidation() + { + await AssertConfigurationFailsAsync( + "at least one software", + system => ConfigureValidGraph( + system, + new GraphOptions { IncludeSoftware = false })) + .ConfigureAwait(false); + } + + [Test] + public async Task ControllerWithoutTaskControlFailsValidation() + { + await AssertConfigurationFailsAsync( + "at least one task control", + system => ConfigureValidGraph( + system, + new GraphOptions { IncludeTaskControl = false })) + .ConfigureAwait(false); + } + + [Test] + public async Task MotionDeviceWithoutAxisFailsValidation() + { + await AssertConfigurationFailsAsync( + "at least one axis", + system => ConfigureValidGraph( + system, + new GraphOptions { IncludeAxis = false })) + .ConfigureAwait(false); + } + + [Test] + public async Task MotionDeviceWithoutPowerTrainFailsValidation() + { + await AssertConfigurationFailsAsync( + "at least one power train", + system => ConfigureValidGraph( + system, + new GraphOptions + { + IncludePowerTrain = false, + VirtualAxis = true + })) + .ConfigureAwait(false); + } + + [Test] + public async Task NonVirtualAxisWithoutRequiresFailsValidation() + { + await AssertConfigurationFailsAsync( + "must Requires", + system => ConfigureValidGraph( + system, + new GraphOptions { AddRequires = false })) + .ConfigureAwait(false); + } + + [Test] + public async Task PowerTrainWithoutMotorFailsValidation() + { + await AssertConfigurationFailsAsync( + "at least one motor", + system => ConfigureValidGraph( + system, + new GraphOptions { IncludeMotor = false })) + .ConfigureAwait(false); + } + + [Test] + public async Task MotorWithoutDriveSucceeds() + { + GraphParts graph = await AddValidGraphAsync( + NextName("NoMotorDrive"), + new GraphOptions { DriveMotor = false }) + .ConfigureAwait(false); + + Assert.That(graph.System.State.NodeId.IsNull, Is.False); + } + + [Test] + public async Task VirtualAxisWithoutRequiresSucceeds() + { + GraphParts graph = await AddValidGraphAsync( + NextName("Virtual"), + new GraphOptions + { + VirtualAxis = true, + AddRequires = false + }) + .ConfigureAwait(false); + + Assert.That(graph.System.State.NodeId.IsNull, Is.False); + } + + [Test] + public async Task CrossScopeRelationshipIsRejected() + { + GraphParts first = await AddValidGraphAsync(NextName("ScopeA")) + .ConfigureAwait(false); + + ServiceResultException exception = + Assert.ThrowsAsync(async () => + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("ScopeB"), system => + { + GraphParts second = ConfigureValidGraph(system); + second.Controller.Controls(first.MotionDevice); + }) + .ConfigureAwait(false))!; + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That(exception.Message, Does.Contain("same MotionDeviceSystem")); + } + + [Test] + public async Task AsyncBindingsAreAttachedMaterializedAndAwaited() + { + var readStarted = new TaskCompletionSource(); + var releaseRead = new TaskCompletionSource(); + var writeStarted = new TaskCompletionSource(); + var releaseWrite = new TaskCompletionSource(); + DateTimeUtc readTimestamp = DateTimeUtc.Now; + IMotionDeviceBuilder motionDevice = null!; + IAxisBuilder axis = null!; + ISafetyStateBuilder safetyState = null!; + IEmergencyStopBuilder emergencyStop = null!; + IProtectiveStopBuilder protectiveStop = null!; + ITaskControlBuilder taskControl = null!; + IMotorBuilder motor = null!; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("Callbacks"), system => + { + GraphParts graph = ConfigureValidGraph(system); + motionDevice = graph.MotionDevice; + axis = graph.Axis; + safetyState = graph.Safety; + motor = graph.Motor; + motionDevice + .BindSpeedOverrideRead(async cancellationToken => + { + readStarted.SetResult(true); + await releaseRead.Task.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new DataValue( + new Variant(88.0), + StatusCodes.Uncertain, + readTimestamp); + }) + .BindSpeedOverrideWrite(async (value, cancellationToken) => + { + Assert.That(value.TryGetValue(out double typed), Is.True); + Assert.That(typed, Is.EqualTo(64)); + writeStarted.SetResult(true); + await releaseWrite.Task.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return ServiceResult.Good; + }); + axis.BindActualPosition( + _ => new ValueTask(new DataValue(9.0))) + .BindActualSpeed( + _ => new ValueTask(new DataValue(2.0))) + .BindActualAcceleration( + _ => new ValueTask(new DataValue(0.2))); + motor.BindMotorTemperature( + _ => new ValueTask(new DataValue(35.0))) + .BindBrakeReleased( + _ => new ValueTask(new DataValue(true))) + .BindEffectiveLoadRate( + _ => new ValueTask(new DataValue((ushort)10))); + safetyState + .BindEmergencyStop( + _ => new ValueTask(new DataValue(false))) + .BindOperationalMode( + _ => new ValueTask( + new DataValue( + Variant.From( + OperationalModeEnumeration.AUTOMATIC)))) + .BindProtectiveStop( + _ => new ValueTask(new DataValue(false))); + emergencyStop = safetyState.AddEmergencyStop( + "BoundEmergencyStop", + "Bound E-Stop", + stop => stop.BindActive( + _ => new ValueTask(new DataValue(false)))); + protectiveStop = safetyState.AddProtectiveStop( + "BoundProtectiveStop", + "Bound Guard", + stop => stop + .BindActive( + _ => new ValueTask(new DataValue(false))) + .BindEnabled( + _ => new ValueTask(new DataValue(true)))); + taskControl = graph.Controller.AddTaskControl( + "BoundTask", + task => task + .BindExecutionMode( + _ => new ValueTask( + new DataValue( + Variant.From(ExecutionModeEnumeration.STEP)))) + .BindTaskProgramLoaded( + _ => new ValueTask(new DataValue(true))) + .BindTaskProgramName( + _ => new ValueTask( + new DataValue(Variant.From("Program"))))); + taskControl.Controls(motionDevice); + }) + .ConfigureAwait(false); + + BaseDataVariableState speedOverride = FindChild>( + motionDevice.State.ParameterSet!, + "SpeedOverride"); + AnalogUnitState actualSpeed = FindChild>( + axis.State.ParameterSet!, + "ActualSpeed"); + AnalogUnitState actualAcceleration = FindChild>( + axis.State.ParameterSet!, + "ActualAcceleration"); + BaseDataVariableState executionMode = + FindChild>( + taskControl.State.ParameterSet!, + "ExecutionMode"); + + StatusCode cachedStatusCode = speedOverride.StatusCode; + DateTimeUtc cachedTimestamp = speedOverride.Timestamp; + Assert.That(speedOverride.OnReadValueAsync, Is.Not.Null); + Assert.That(speedOverride.OnSimpleReadValueAsync, Is.Null); + Assert.That(speedOverride.OnSimpleWriteValueAsync, Is.Not.Null); + Assert.That(actualSpeed.OnReadValueAsync, Is.Not.Null); + Assert.That(actualAcceleration.OnReadValueAsync, Is.Not.Null); + Assert.That(executionMode.OnReadValueAsync, Is.Not.Null); + Assert.That(emergencyStop.State.Active!.OnReadValueAsync, Is.Not.Null); + Assert.That(protectiveStop.State.Enabled!.OnReadValueAsync, Is.Not.Null); + + ValueTask<(ServiceResult, DataValue)> readTask = speedOverride.ReadAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + new DataValue()); + await readStarted.Task.ConfigureAwait(false); + Assert.That(readTask.IsCompleted, Is.False); + releaseRead.SetResult(true); + (ServiceResult readResult, DataValue value) = + await readTask.ConfigureAwait(false); + Assert.That(ServiceResult.IsBad(readResult), Is.False); + Assert.That(value.WrappedValue.GetDouble(), Is.EqualTo(88)); + Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Uncertain)); + Assert.That(value.SourceTimestamp, Is.EqualTo(readTimestamp)); + Assert.That(speedOverride.StatusCode, Is.EqualTo(cachedStatusCode)); + Assert.That(speedOverride.Timestamp, Is.EqualTo(cachedTimestamp)); + + ValueTask writeTask = speedOverride.WriteAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + new DataValue(64.0)); + await writeStarted.Task.ConfigureAwait(false); + Assert.That(writeTask.IsCompleted, Is.False); + releaseWrite.SetResult(true); + ServiceResult writeResult = await writeTask.ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(writeResult), Is.True); + Assert.That(speedOverride.Value, Is.EqualTo(64)); + } + + [Test] + public async Task ConcurrentReadsKeepValueStatusAndTimestampTogether() + { + var firstReadStarted = new TaskCompletionSource(); + var releaseFirstRead = new TaskCompletionSource(); + DateTimeUtc firstTimestamp = DateTimeUtc.Now; + DateTimeUtc secondTimestamp = + (DateTimeUtc)firstTimestamp.ToDateTime().AddSeconds(1); + int readCount = 0; + IMotionDeviceBuilder motionDevice = null!; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("ConcurrentReads"), system => + { + GraphParts graph = ConfigureValidGraph(system); + motionDevice = graph.MotionDevice; + motionDevice.BindSpeedOverrideRead(async cancellationToken => + { + int readNumber = Interlocked.Increment(ref readCount); + if (readNumber == 1) + { + firstReadStarted.SetResult(true); + await releaseFirstRead.Task.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new DataValue( + new Variant(25.0), + StatusCodes.Uncertain, + firstTimestamp); + } + + return new DataValue( + new Variant(75.0), + StatusCodes.GoodClamped, + secondTimestamp); + }); + }) + .ConfigureAwait(false); + + BaseDataVariableState speedOverride = FindChild>( + motionDevice.State.ParameterSet!, + "SpeedOverride"); + ValueTask<(ServiceResult, DataValue)> firstRead = speedOverride.ReadAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + new DataValue()); + await firstReadStarted.Task.ConfigureAwait(false); + ValueTask<(ServiceResult, DataValue)> secondRead = speedOverride.ReadAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + new DataValue()); + + (ServiceResult secondResult, DataValue secondValue) = + await secondRead.ConfigureAwait(false); + releaseFirstRead.SetResult(true); + (ServiceResult firstResult, DataValue firstValue) = + await firstRead.ConfigureAwait(false); + + Assert.That(firstResult.StatusCode, Is.EqualTo(StatusCodes.Uncertain)); + Assert.That(firstValue.WrappedValue.GetDouble(), Is.EqualTo(25)); + Assert.That(firstValue.StatusCode, Is.EqualTo(StatusCodes.Uncertain)); + Assert.That(firstValue.SourceTimestamp, Is.EqualTo(firstTimestamp)); + Assert.That(secondResult.StatusCode, Is.EqualTo(StatusCodes.GoodClamped)); + Assert.That(secondValue.WrappedValue.GetDouble(), Is.EqualTo(75)); + Assert.That(secondValue.StatusCode, Is.EqualTo(StatusCodes.GoodClamped)); + Assert.That(secondValue.SourceTimestamp, Is.EqualTo(secondTimestamp)); + } + + [Test] + public async Task ScalarReadBindingReturnsFrameworkIndexRangeError() + { + IMotionDeviceBuilder motionDevice = null!; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("ScalarRange"), system => + { + GraphParts graph = ConfigureValidGraph(system); + motionDevice = graph.MotionDevice; + motionDevice.BindSpeedOverrideRead( + _ => new ValueTask(new DataValue(50.0))); + }) + .ConfigureAwait(false); + + BaseDataVariableState speedOverride = FindChild>( + motionDevice.State.ParameterSet!, + "SpeedOverride"); + (ServiceResult result, DataValue value) = + await speedOverride.ReadAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + new NumericRange(0), + QualifiedName.Null, + new DataValue()) + .ConfigureAwait(false); + + Assert.That( + result.StatusCode, + Is.EqualTo(StatusCodes.BadIndexRangeNoData)); + Assert.That(value.WrappedValue.IsNull, Is.True); + } + + [Test] + public async Task SpeedOverrideRejectsInvalidStaticValues() + { + double[] invalidValues = + [ + -1, + 101, + double.NaN, + double.PositiveInfinity, + double.NegativeInfinity + ]; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("InvalidSpeedStatic"), system => + { + GraphParts graph = ConfigureValidGraph(system); + for (int ii = 0; ii < invalidValues.Length; ii++) + { + double value = invalidValues[ii]; + Assert.That( + () => graph.MotionDevice.WithSpeedOverride(value), + Throws.TypeOf()); + } + }) + .ConfigureAwait(false); + } + + [Test] + public async Task SpeedOverrideReadBindingValidatesGoodValues() + { + DataValue callbackValue = new DataValue(50.0); + IMotionDeviceBuilder motionDevice = null!; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("InvalidSpeedRead"), system => + { + GraphParts graph = ConfigureValidGraph(system); + motionDevice = graph.MotionDevice; + motionDevice.BindSpeedOverrideRead( + _ => new ValueTask(callbackValue)); + }) + .ConfigureAwait(false); + + BaseDataVariableState speedOverride = FindChild>( + motionDevice.State.ParameterSet!, + "SpeedOverride"); + (DataValue Value, StatusCode Expected)[] invalidValues = + [ + (new DataValue(Variant.From("invalid")), StatusCodes.BadTypeMismatch), + (new DataValue(-1.0), StatusCodes.BadOutOfRange), + (new DataValue(101.0), StatusCodes.BadOutOfRange), + (new DataValue(double.NaN), StatusCodes.BadOutOfRange), + (new DataValue(double.PositiveInfinity), StatusCodes.BadOutOfRange), + (new DataValue(double.NegativeInfinity), StatusCodes.BadOutOfRange) + ]; + + for (int ii = 0; ii < invalidValues.Length; ii++) + { + callbackValue = invalidValues[ii].Value; + (ServiceResult result, DataValue value) = + await speedOverride.ReadAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + new DataValue()) + .ConfigureAwait(false); + + Assert.That( + result.StatusCode, + Is.EqualTo(invalidValues[ii].Expected)); + Assert.That(value.WrappedValue.IsNull, Is.True); + } + + callbackValue = new DataValue( + Variant.From("offline"), + StatusCodes.BadNotConnected); + (ServiceResult badResult, _) = await speedOverride.ReadAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + new DataValue()) + .ConfigureAwait(false); + Assert.That(badResult.StatusCode, Is.EqualTo(StatusCodes.BadNotConnected)); + + callbackValue = DataValue.Null; + (ServiceResult noDataResult, _) = await speedOverride.ReadAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + new DataValue()) + .ConfigureAwait(false); + Assert.That( + noDataResult.StatusCode, + Is.EqualTo(StatusCodes.BadNoDataAvailable)); + } + + [Test] + public async Task SpeedOverrideReadBindingValidatesUncertainValues() + { + DataValue callbackValue = new DataValue(50.0); + IMotionDeviceBuilder motionDevice = null!; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("UncertainSpeedRead"), system => + { + GraphParts graph = ConfigureValidGraph(system); + motionDevice = graph.MotionDevice; + motionDevice.BindSpeedOverrideRead( + _ => new ValueTask(callbackValue)); + }) + .ConfigureAwait(false); + + BaseDataVariableState speedOverride = FindChild>( + motionDevice.State.ParameterSet!, + "SpeedOverride"); + (DataValue Value, StatusCode Expected)[] invalidValues = + [ + ( + new DataValue( + Variant.From("invalid"), + StatusCodes.Uncertain), + StatusCodes.BadTypeMismatch), + ( + new DataValue( + Variant.From(101.0), + StatusCodes.Uncertain), + StatusCodes.BadOutOfRange), + ( + new DataValue( + Variant.From(double.NaN), + StatusCodes.Uncertain), + StatusCodes.BadOutOfRange) + ]; + + for (int ii = 0; ii < invalidValues.Length; ii++) + { + callbackValue = invalidValues[ii].Value; + (ServiceResult result, DataValue value) = + await speedOverride.ReadAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + QualifiedName.Null, + new DataValue()) + .ConfigureAwait(false); + + Assert.That( + result.StatusCode, + Is.EqualTo(invalidValues[ii].Expected)); + Assert.That(value.WrappedValue.IsNull, Is.True); + } + } + + [Test] + public async Task SpeedOverrideWriteBindingValidatesBeforeHandler() + { + int handlerCalls = 0; + IMotionDeviceBuilder motionDevice = null!; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("InvalidSpeedWrite"), system => + { + GraphParts graph = ConfigureValidGraph(system); + motionDevice = graph.MotionDevice; + motionDevice.BindSpeedOverrideWrite((_, _) => + { + handlerCalls++; + return new ValueTask(ServiceResult.Good); + }); + }) + .ConfigureAwait(false); + + BaseDataVariableState speedOverride = FindChild>( + motionDevice.State.ParameterSet!, + "SpeedOverride"); + (Variant Value, StatusCode Expected)[] invalidValues = + [ + (Variant.From("invalid"), StatusCodes.BadTypeMismatch), + (Variant.From(-1.0), StatusCodes.BadOutOfRange), + (Variant.From(101.0), StatusCodes.BadOutOfRange), + (Variant.From(double.NaN), StatusCodes.BadOutOfRange), + (Variant.From(double.PositiveInfinity), StatusCodes.BadOutOfRange), + (Variant.From(double.NegativeInfinity), StatusCodes.BadOutOfRange) + ]; + + for (int ii = 0; ii < invalidValues.Length; ii++) + { + ServiceResult result = await speedOverride.WriteAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + new DataValue(invalidValues[ii].Value)) + .ConfigureAwait(false); + + Assert.That( + result.StatusCode, + Is.EqualTo(invalidValues[ii].Expected)); + Assert.That(handlerCalls, Is.Zero); + } + + ServiceResult goodResult = await speedOverride.WriteAttributeAsync( + motionDevice.BuildContext.Context, + Attributes.Value, + NumericRange.Null, + new DataValue(50.0)) + .ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(goodResult), Is.True); + Assert.That(handlerCalls, Is.EqualTo(1)); + } + + [Test] + public async Task ReadBindingRejectsEitherOccupiedAsyncReadSlot() + { + ServiceResultException? fullSlotException = null; + ServiceResultException? simpleSlotException = null; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("OccupiedReadSlots"), system => + { + GraphParts graph = ConfigureValidGraph(system); + BaseDataVariableState speedOverride = FindChild>( + graph.MotionDevice.State.ParameterSet!, + "SpeedOverride"); + speedOverride.OnReadValueAsync = (_, _, _, _, _) => + new ValueTask( + new AttributeReadResult( + ServiceResult.Good, + Variant.From(1.0), + StatusCodes.Good, + DateTimeUtc.Now)); + + fullSlotException = Assert.Throws( + () => graph.MotionDevice.BindSpeedOverrideRead( + _ => new ValueTask(new DataValue(1.0)))); + + speedOverride.OnReadValueAsync = null; + speedOverride.OnSimpleReadValueAsync = (_, _, _) => + new ValueTask( + new AttributeSimpleReadResult( + ServiceResult.Good, + Variant.From(1.0))); + simpleSlotException = Assert.Throws( + () => graph.MotionDevice.BindSpeedOverrideRead( + _ => new ValueTask(new DataValue(1.0)))); + }) + .ConfigureAwait(false); + + Assert.That(fullSlotException!.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + Assert.That(simpleSlotException!.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + } + + [Test] + public async Task WriteBindingRejectsEitherOccupiedAsyncWriteSlot() + { + ServiceResultException? fullSlotException = null; + ServiceResultException? simpleSlotException = null; + int boundHandlerCalls = 0; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("OccupiedWriteSlots"), system => + { + GraphParts graph = ConfigureValidGraph(system); + BaseDataVariableState speedOverride = FindChild>( + graph.MotionDevice.State.ParameterSet!, + "SpeedOverride"); + speedOverride.OnWriteValueAsync = (_, _, _, _, _) => + new ValueTask( + new AttributeWriteResult(ServiceResult.Good)); + + fullSlotException = Assert.Throws( + () => graph.MotionDevice.BindSpeedOverrideWrite((_, _) => + { + boundHandlerCalls++; + return new ValueTask(ServiceResult.Good); + })); + + speedOverride.OnWriteValueAsync = null; + speedOverride.OnSimpleWriteValueAsync = (_, _, _, _) => + new ValueTask( + new AttributeWriteResult(ServiceResult.Good)); + simpleSlotException = Assert.Throws( + () => graph.MotionDevice.BindSpeedOverrideWrite((_, _) => + { + boundHandlerCalls++; + return new ValueTask(ServiceResult.Good); + })); + }) + .ConfigureAwait(false); + + Assert.That(fullSlotException!.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + Assert.That(simpleSlotException!.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + Assert.That(boundHandlerCalls, Is.Zero); + } + + [Test] + public async Task GearRatioPreservesSignedNumeratorAndPitchMustBeFinite() + { + IGearBuilder gear = null!; + + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync(NextName("GearValues"), system => + { + GraphParts graph = ConfigureValidGraph(system); + gear = graph.PowerTrain.AddGear( + "Gear", + item => item.WithGearRatio(-7, 3)); + + Assert.That( + () => gear.WithPitch(double.NaN), + Throws.TypeOf()); + Assert.That( + () => gear.WithPitch(double.PositiveInfinity), + Throws.TypeOf()); + Assert.That( + () => gear.WithPitch(double.NegativeInfinity), + Throws.TypeOf()); + gear.WithPitch(12.5); + }) + .ConfigureAwait(false); + + Assert.That(gear.State.GearRatio!.Value.Numerator, Is.EqualTo(-7)); + Assert.That(gear.State.GearRatio.Value.Denominator, Is.EqualTo(3)); + Assert.That(gear.State.Pitch!.Value, Is.EqualTo(12.5)); + } + + private ValueTask AddValidGraphAsync( + string browseName, + GraphOptions? options = null) + { + return AddValidGraphAsync( + m_fixture.CreateBuildContext(), + browseName, + options); + } + + private static async ValueTask AddValidGraphAsync( + IRoboticsBuildContext context, + string browseName, + GraphOptions? options = null) + { + var result = new GraphParts(); + result.System = await context + .AddMotionDeviceSystemAsync( + browseName, + system => Copy(ConfigureValidGraph(system, options), result)) + .ConfigureAwait(false); + return result; + } + + private static GraphParts ConfigureValidGraph( + IMotionDeviceSystemBuilder system, + GraphOptions? options = null) + { + options ??= new GraphOptions(); + var graph = new GraphParts(); + + if (options.IncludeMotionDevice) + { + graph.MotionDevice = system.AddMotionDevice("MotionDevice", motion => + { + motion.WithCategory(MotionDeviceCategoryEnumeration.OTHER) + .WithSpeedOverride(100); + }); + graph.Drive = graph.MotionDevice.AddDrive( + "Drive", + drive => drive.WithProductCode("DRIVE")); + + if (options.IncludeAxis) + { + graph.Axis = graph.MotionDevice.AddAxis("Axis", axis => + { + axis.WithMotionProfile(AxisMotionProfileEnumeration.OTHER) + .WithActualPosition(0); + if (options.VirtualAxis) + { + axis.AsVirtual(); + } + }); + } + + if (options.IncludePowerTrain) + { + graph.PowerTrain = graph.MotionDevice.AddPowerTrain("PowerTrain"); + if (options.IncludeMotor) + { + graph.Motor = graph.PowerTrain.AddMotor( + "Motor", + motor => motor.WithMotorTemperature(20)); + if (options.DriveMotor) + { + graph.Motor.IsDrivenBy(graph.Drive); + } + } + if (options.IncludeAxis && options.AddRequires) + { + graph.Axis.Requires(graph.PowerTrain); + graph.PowerTrain.Moves(graph.Axis); + } + } + } + + if (options.IncludeSafetyState) + { + graph.Safety = system.AddSafetyState("Safety", safety => + safety.WithEmergencyStop(false) + .WithOperationalMode(OperationalModeEnumeration.OTHER) + .WithProtectiveStop(false)); + } + + if (options.IncludeController) + { + graph.Controller = system.AddController("Controller"); + if (options.IncludeSoftware) + { + graph.Software = graph.Controller.AddSoftware( + "Software", + software => software.WithIdentification(data => + { + data.Manufacturer = new LocalizedText("Vendor"); + data.Model = new LocalizedText("Runtime"); + data.SoftwareRevision = "1.0"; + })); + } + if (options.IncludeTaskControl) + { + graph.TaskControl = graph.Controller.AddTaskControl( + "TaskControl", + taskControl => taskControl.WithComponentName("Task Control")); + } + if (options.IncludeMotionDevice && options.AddControllerControls) + { + graph.Controller.Controls(graph.MotionDevice); + } + } + + return graph; + } + + private async Task AssertConfigurationFailsAsync( + string expectedMessage, + Action configure) + { + ServiceResultException exception = + Assert.ThrowsAsync(async () => + await m_fixture.CreateBuildContext() + .AddMotionDeviceSystemAsync( + NextName("Invalid"), + configure) + .ConfigureAwait(false))!; + + Assert.That( + exception.StatusCode, + Is.EqualTo(StatusCodes.BadConfigurationError)); + Assert.That(exception.Message, Does.Contain(expectedMessage)); + } + + private void AssertTreeRegisteredAndUnique(NodeState root) + { + _ = AssertTreeRegisteredAndUnique( + m_fixture.Manager, + root, + m_fixture.Manager.InstanceNamespaceIndex); + } + + private static HashSet AssertTreeRegisteredAndUnique( + DiNodeManager manager, + NodeState root, + ushort instanceNamespaceIndex) + { + var nodes = new List { root }; + var children = new List(); + var nodeIds = new HashSet(); + for (int ii = 0; ii < nodes.Count; ii++) + { + NodeState node = nodes[ii]; + Assert.That(node.NodeId.IsNull, Is.False, node.BrowseName.ToString()); + Assert.That(nodeIds.Add(node.NodeId), Is.True, node.NodeId.ToString()); + Assert.That( + node.NodeId.NamespaceIndex, + Is.EqualTo(instanceNamespaceIndex)); + Assert.That( + manager.FindPredefinedNode(node.NodeId), + Is.SameAs(node)); + + children.Clear(); + node.GetChildren(manager.SystemContext, children); + for (int childIndex = 0; childIndex < children.Count; childIndex++) + { + nodes.Add(children[childIndex]); + } + } + return nodeIds; + } + + private HashSet GetNodeIds(NodeState root) + { + var result = new HashSet(); + var nodes = new List { root }; + var children = new List(); + for (int ii = 0; ii < nodes.Count; ii++) + { + result.Add(nodes[ii].NodeId); + children.Clear(); + nodes[ii].GetChildren(m_fixture.Manager.SystemContext, children); + for (int childIndex = 0; childIndex < children.Count; childIndex++) + { + nodes.Add(children[childIndex]); + } + } + return result; + } + + private void AssertReference( + NodeState source, + uint referenceTypeIdentifier, + NodeState target) + { + NodeId referenceTypeId = NodeId.Create( + referenceTypeIdentifier, + Robotics.Namespaces.Robotics, + m_fixture.Manager.SystemContext.NamespaceUris); + Assert.That( + source.ReferenceExists(referenceTypeId, false, target.NodeId), + Is.True); + Assert.That( + target.ReferenceExists(referenceTypeId, true, source.NodeId), + Is.True); + } + + private TChild FindChild(NodeState parent, string browseName) + where TChild : BaseInstanceState + { + var children = new List(); + parent.GetChildren(m_fixture.Manager.SystemContext, children); + for (int ii = 0; ii < children.Count; ii++) + { + if (children[ii] is TChild typed && + children[ii].BrowseName.Name == browseName) + { + return typed; + } + } + Assert.Fail( + $"Child '{browseName}' of type '{typeof(TChild).Name}' " + + $"was not found below '{parent.BrowseName}'."); + return null!; + } + + private static List GetChildren( + ISystemContext context, + NodeState parent) + { + var children = new List(); + parent.GetChildren(context, children); + return children; + } + + private string NextName(string prefix) + { + return $"{prefix}{Interlocked.Increment(ref m_nameCounter)}"; + } + + private static void Copy(GraphParts source, GraphParts target) + { + target.Controller = source.Controller; + target.MotionDevice = source.MotionDevice; + target.Safety = source.Safety; + target.Axis = source.Axis; + target.PowerTrain = source.PowerTrain; + target.Motor = source.Motor; + target.Drive = source.Drive; + target.Software = source.Software; + target.TaskControl = source.TaskControl; + } + + private sealed class GraphOptions + { + public bool IncludeController { get; set; } = true; + + public bool IncludeMotionDevice { get; set; } = true; + + public bool IncludeSafetyState { get; set; } = true; + + public bool IncludeAxis { get; set; } = true; + + public bool IncludePowerTrain { get; set; } = true; + + public bool IncludeMotor { get; set; } = true; + + public bool IncludeSoftware { get; set; } = true; + + public bool IncludeTaskControl { get; set; } = true; + + public bool AddControllerControls { get; set; } = true; + + public bool AddRequires { get; set; } = true; + + public bool DriveMotor { get; set; } = true; + + public bool VirtualAxis { get; set; } + } + + private class GraphParts + { + public IMotionDeviceSystemBuilder System { get; set; } = null!; + + public IControllerBuilder Controller { get; set; } = null!; + + public IMotionDeviceBuilder MotionDevice { get; set; } = null!; + + public ISafetyStateBuilder Safety { get; set; } = null!; + + public IAxisBuilder Axis { get; set; } = null!; + + public IPowerTrainBuilder PowerTrain { get; set; } = null!; + + public IMotorBuilder Motor { get; set; } = null!; + + public IDriveBuilder Drive { get; set; } = null!; + + public IRoboticsSoftwareBuilder Software { get; set; } = null!; + + public ITaskControlBuilder TaskControl { get; set; } = null!; + } + + private sealed class FullGraph : GraphParts + { + public IGearBuilder Gear { get; set; } = null!; + + public IEmergencyStopBuilder EmergencyStop { get; set; } = null!; + + public IProtectiveStopBuilder ProtectiveStop { get; set; } = null!; + + public ITaskModuleBuilder TaskModule { get; set; } = null!; + + public IPowerTrainBuilder SlavePowerTrain { get; set; } = null!; + + public IMotorBuilder SlaveMotor { get; set; } = null!; + } + + private sealed class CustomRoboticsNodeManager : + DiNodeManager, + IRoboticsNodeIdFactory + { + public const string InstanceNamespaceUri = + "urn:tests:robotics:custom-manager:instances"; + + public CustomRoboticsNodeManager( + IServerInternal server, + ApplicationConfiguration configuration) + : base( + server, + configuration, + Opc.Ua.IA.Namespaces.IA, + Robotics.Namespaces.Robotics, + InstanceNamespaceUri) + { + } + + public ushort InstanceNamespaceIndex => + (ushort)SystemContext.NamespaceUris.GetIndex(InstanceNamespaceUri); + + public int PredefinedNodeCount => PredefinedNodes.Count; + + public override NodeId New(ISystemContext context, NodeState node) + { + return m_nodeIdAllocator.New( + this, + InstanceNamespaceIndex, + node); + } + + public void PauseNextRootRegistrations(int count) + { + if (count <= 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + lock (m_registrationPauseLock) + { + if (m_resumeRootRegistrations != null) + { + throw new InvalidOperationException( + "A root-registration pause is already active."); + } + + m_rootRegistrationsToPause = count; + m_rootRegistrationsPaused = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + m_resumeRootRegistrations = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + } + } + + public Task WaitForPausedRootRegistrationsAsync() + { + lock (m_registrationPauseLock) + { + return m_rootRegistrationsPaused?.Task ?? + throw new InvalidOperationException( + "No root-registration pause is active."); + } + } + + public void ResumeRootRegistrations() + { + TaskCompletionSource resume; + lock (m_registrationPauseLock) + { + resume = m_resumeRootRegistrations ?? + throw new InvalidOperationException( + "No root-registration pause is active."); + m_rootRegistrationsPaused = null; + m_resumeRootRegistrations = null; + m_rootRegistrationsToPause = 0; + } + resume.TrySetResult(true); + } + + protected override ValueTask LoadPredefinedNodesAsync( + ISystemContext context, + CancellationToken cancellationToken = default) + { + var nodes = new NodeStateCollection(); + nodes.AddRoboticsTypeSystem(context); + return new ValueTask(nodes); + } + + protected override async ValueTask AddPredefinedNodeAsync( + ISystemContext context, + NodeState node, + CancellationToken cancellationToken = default) + { + Task? resumeTask = null; + TaskCompletionSource? paused = null; + lock (m_registrationPauseLock) + { + if (node is MotionDeviceSystemState && + m_rootRegistrationsToPause > 0) + { + m_rootRegistrationsToPause--; + resumeTask = m_resumeRootRegistrations!.Task; + if (m_rootRegistrationsToPause == 0) + { + paused = m_rootRegistrationsPaused; + } + } + } + + paused?.TrySetResult(true); + if (resumeTask != null) + { + await resumeTask.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + await base.AddPredefinedNodeAsync(context, node, cancellationToken) + .ConfigureAwait(false); + } + + private readonly Lock m_registrationPauseLock = new(); + private readonly TestRoboticsNodeIdAllocator m_nodeIdAllocator = new(); + private TaskCompletionSource? m_resumeRootRegistrations; + private TaskCompletionSource? m_rootRegistrationsPaused; + private int m_rootRegistrationsToPause; + } + + private sealed class MissingRoboticsModelNodeManager : + DiNodeManager, + IRoboticsNodeIdFactory + { + public const string InstanceNamespaceUri = + "urn:tests:robotics:missing-model:instances"; + + public MissingRoboticsModelNodeManager( + IServerInternal server, + ApplicationConfiguration configuration) + : base( + server, + configuration, + Opc.Ua.IA.Namespaces.IA, + Robotics.Namespaces.Robotics, + InstanceNamespaceUri) + { + } + + public ushort InstanceNamespaceIndex => + (ushort)SystemContext.NamespaceUris.GetIndex(InstanceNamespaceUri); + + public override NodeId New(ISystemContext context, NodeState node) + { + return m_nodeIdAllocator.New( + this, + InstanceNamespaceIndex, + node); + } + + private readonly TestRoboticsNodeIdAllocator m_nodeIdAllocator = new(); + } + + private sealed class UnmarkedRoboticsNodeManager : DiNodeManager + { + public const string InstanceNamespaceUri = + "urn:tests:robotics:unmarked-manager:instances"; + + public UnmarkedRoboticsNodeManager( + IServerInternal server, + ApplicationConfiguration configuration) + : base( + server, + configuration, + Opc.Ua.IA.Namespaces.IA, + Robotics.Namespaces.Robotics, + InstanceNamespaceUri) + { + } + + protected override ValueTask LoadPredefinedNodesAsync( + ISystemContext context, + CancellationToken cancellationToken = default) + { + var nodes = new NodeStateCollection(); + nodes.AddRoboticsTypeSystem(context); + return new ValueTask(nodes); + } + } + + private sealed class TestRoboticsNodeIdAllocator + { + public NodeId New( + DiNodeManager manager, + ushort namespaceIndex, + NodeState node) + { + if (!node.NodeId.IsNull) + { + return node.NodeId; + } + + lock (m_lock) + { + while (true) + { + uint identifier = + Utils.IncrementIdentifier(ref m_lastUsedNodeId); + var candidate = new NodeId(identifier, namespaceIndex); + if (m_reservedNodeIds.Contains(candidate) || + manager.FindPredefinedNode(candidate) != null) + { + continue; + } + + m_reservedNodeIds.Add(candidate); + return candidate; + } + } + } + + private readonly HashSet m_reservedNodeIds = []; + private readonly Lock m_lock = new(); + private uint m_lastUsedNodeId; + } + } +} diff --git a/tests/Opc.Ua.Server.Tests/FileSystem/FileDirectoryBinderTests.cs b/tests/Opc.Ua.Server.Tests/FileSystem/FileDirectoryBinderTests.cs new file mode 100644 index 0000000000..34d5fb37af --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/FileSystem/FileDirectoryBinderTests.cs @@ -0,0 +1,865 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Opc.Ua.Server.FileSystem; +using Opc.Ua.Server.Fluent; +using Opc.Ua.Server.Hosting; +using Opc.Ua.Tests; + +namespace Opc.Ua.Server.Tests.FileSystem +{ + /// + /// Tests for materialised FileDirectoryType bindings. + /// + [TestFixture] + [Category("FileSystem")] + public class FileDirectoryBinderTests + { + [Test] + public async Task BindMaterialisesFilesAndDirectoriesWithoutInitialRegistrationsAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(); + provider.AddFile("readme.txt", "hello"); + provider.AddDirectory("programs"); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + var registered = new List(); + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, + provider, + context, + registerNode: (node, _) => + { + registered.Add(node); + return default; + }).ConfigureAwait(false); + + Assert.That(binding.Directory, Is.SameAs(root)); + Assert.That(binding.Provider, Is.SameAs(provider)); + Assert.That(Find(root, context, "readme.txt"), Is.Not.Null); + Assert.That(Find(root, context, "programs"), Is.Not.Null); + Assert.That(registered, Is.Empty); + } + + [Test] + public async Task ReadFileReturnsProviderContentAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(); + provider.AddFile("program.mod", "movej"); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, provider, context).ConfigureAwait(false); + FileState file = Find(root, context, "program.mod")!; + + byte[] content = ReadAll(context, file); + + Assert.That(Encoding.UTF8.GetString(content), Is.EqualTo("movej")); + } + + [Test] + public async Task WriteFileUpdatesProviderContentAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(); + provider.AddFile("program.mod", "old"); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, provider, context).ConfigureAwait(false); + FileState file = Find(root, context, "program.mod")!; + + WriteAll(context, file, "new-content"); + + Assert.That(provider.ReadText("program.mod"), Is.EqualTo("new-content")); + } + + [Test] + public async Task CreateReturnsAccessDeniedWhenProviderIsReadOnlyAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(isWritable: false); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, provider, context).ConfigureAwait(false); + + CreateFileMethodStateResult result = await root.CreateFile!.OnCallAsync!( + context, root.CreateFile!, root.NodeId, "denied.txt", false, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + } + + [Test] + public async Task CreateReturnsAccessDeniedWhenOptionsWithholdCreateAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + var options = new FileDirectoryBindingOptions { AllowCreate = false }; + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, provider, context, options).ConfigureAwait(false); + + CreateDirectoryMethodStateResult result = await root.CreateDirectory!.OnCallAsync!( + context, root.CreateDirectory!, root.NodeId, "denied", CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + } + + [Test] + [TestCase("../escape")] + [TestCase("..\\escape")] + [TestCase("sub/child")] + [TestCase("sub\\child")] + [TestCase("/rooted")] + [TestCase("\\rooted")] + [TestCase("C:\\Windows\\System32\\evil")] + [TestCase("..")] + [TestCase(".")] + [TestCase("stream:name")] + [TestCase(" ")] + public async Task CreateRejectsANameThatIsNotASingleSegmentAsync(string name) + { + InMemoryFileSystemProvider provider = CreateProvider(); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, provider, context).ConfigureAwait(false); + + CreateDirectoryMethodStateResult directoryResult = + await root.CreateDirectory!.OnCallAsync!( + context, root.CreateDirectory!, root.NodeId, name, CancellationToken.None) + .ConfigureAwait(false); + CreateFileMethodStateResult fileResult = await root.CreateFile!.OnCallAsync!( + context, root.CreateFile!, root.NodeId, name, false, CancellationToken.None) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That( + directoryResult.ServiceResult.StatusCode, + Is.EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That( + fileResult.ServiceResult.StatusCode, + Is.EqualTo(StatusCodes.BadInvalidArgument)); + }); + } + + [Test] + public async Task MoveOrCopyRejectsANewNameThatIsNotASingleSegmentAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(); + provider.AddDirectory("target"); + provider.AddFile("old.txt", "x"); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, provider, context).ConfigureAwait(false); + + NodeId sourceId = Find(root, context, "old.txt")!.NodeId; + NodeId targetId = Find(root, context, "target")!.NodeId; + + MoveOrCopyMethodStateResult result = await root.MoveOrCopy!.OnCallAsync!( + context, + root.MoveOrCopy!, + root.NodeId, + sourceId, + targetId, + false, + "..\\..\\escape", + CancellationToken.None).ConfigureAwait(false); + + Assert.That( + result.ServiceResult.StatusCode, + Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public async Task CreateDeleteAndMoveReconcileMaterialisedNodesAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(); + provider.AddDirectory("target"); + provider.AddFile("old.txt", "x"); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + var registered = new List(); + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, + provider, + context, + registerNode: (node, _) => + { + registered.Add(node); + return default; + }).ConfigureAwait(false); + + CreateFileMethodStateResult createResult = await root.CreateFile!.OnCallAsync!( + context, root.CreateFile!, root.NodeId, "new.txt", false, CancellationToken.None) + .ConfigureAwait(false); + FileDirectoryState target = Find(root, context, "target")!; + FileState oldFile = Find(root, context, "old.txt")!; + MoveOrCopyMethodStateResult moveResult = await root.MoveOrCopy!.OnCallAsync!( + context, root.MoveOrCopy!, root.NodeId, oldFile.NodeId, target.NodeId, false, "moved.txt", + CancellationToken.None).ConfigureAwait(false); + FileState newFile = Find(root, context, "new.txt")!; + DeleteFileMethodStateResult deleteResult = await root.DeleteFileSystemObject!.OnCallAsync!( + context, root.DeleteFileSystemObject!, root.NodeId, newFile.NodeId, CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(createResult.ServiceResult), Is.True); + Assert.That(ServiceResult.IsGood(moveResult.ServiceResult), Is.True); + Assert.That(ServiceResult.IsGood(deleteResult.ServiceResult), Is.True); + Assert.That(provider.Exists("new.txt"), Is.False); + Assert.That(provider.Exists("target/moved.txt"), Is.True); + Assert.That(Find(root, context, "new.txt"), Is.Null); + Assert.That(Find(target, context, "moved.txt"), Is.Not.Null); + Assert.That(registered, Is.Not.Empty); + } + + [Test] + public void MaxEntriesThrowsBeforeMaterialisingOverflow() + { + InMemoryFileSystemProvider provider = CreateProvider(); + provider.AddFile("a.txt", "a"); + provider.AddFile("b.txt", "b"); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + var options = new FileDirectoryBindingOptions { MaxEntries = 1 }; + + Assert.That( + async () => await CreateBinder().BindAsync(root, provider, context, options).ConfigureAwait(false), + Throws.TypeOf()); + Assert.That(Find(root, context, "a.txt"), Is.Null); + } + + [Test] + public async Task MaxDepthStopsNestedMaterialisationAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(); + provider.AddDirectory("level1"); + provider.AddFile("level1/deep.txt", "deep"); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + var options = new FileDirectoryBindingOptions { MaxDepth = 1 }; + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, provider, context, options).ConfigureAwait(false); + FileDirectoryState level1 = Find(root, context, "level1")!; + + Assert.That(level1, Is.Not.Null); + Assert.That(Find(level1, context, "deep.txt"), Is.Null); + } + + [Test] + public async Task RefreshAddsAndRemovesChildrenAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(); + provider.AddFile("old.txt", "old"); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + + await using IFileDirectoryBinding binding = await CreateBinder().BindAsync( + root, provider, context).ConfigureAwait(false); + provider.Delete("old.txt"); + provider.AddFile("new.txt", "new"); + + await binding.RefreshAsync().ConfigureAwait(false); + + Assert.That(Find(root, context, "old.txt"), Is.Null); + Assert.That(Find(root, context, "new.txt"), Is.Not.Null); + } + + [Test] + public async Task DisposeClosesHandlesAndDetachesCallbacksAsync() + { + InMemoryFileSystemProvider provider = CreateProvider(); + provider.AddFile("open.txt", "content"); + FileDirectoryState root = CreateRoot(); + SessionSystemContext context = CreateContext(); + + IFileDirectoryBinding binding = await CreateBinder().BindAsync(root, provider, context).ConfigureAwait(false); + FileState file = Find(root, context, "open.txt")!; + _ = Open(context, file, 0x1); + + await binding.DisposeAsync().ConfigureAwait(false); + + Assert.That(provider.OpenStreamCount, Is.Zero); + Assert.That(file.Close!.OnCall, Is.Null); + Assert.That(root.CreateFile!.OnCallAsync!, Is.Null); + } + + [Test] + public void AddFileDirectoryBinderRegistersDefaultBinder() + { + var services = new ServiceCollection(); + services.AddSingleton(NUnitTelemetryContext.Create()); + IOpcUaServerBuilder builder = new TestServerBuilder(services); + + builder.AddFileDirectoryBinder(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.That(provider.GetService(), Is.TypeOf()); + } + + private static FileDirectoryBinder CreateBinder() + { + return new FileDirectoryBinder(NUnitTelemetryContext.Create()); + } + + private static SessionSystemContext CreateContext() + { + var namespaceUris = new NamespaceTable(); + namespaceUris.Append("http://opcfoundation.org/UA/"); + namespaceUris.Append("urn:test"); + return new SessionSystemContext(NUnitTelemetryContext.Create()) + { + NamespaceUris = namespaceUris, + ServerUris = new StringTable(), + TypeTable = new TypeTable(namespaceUris), + SessionId = new NodeId("test-session", 0) + }; + } + + private static FileDirectoryState CreateRoot() + { + return new FileDirectoryState(null) + { + TypeDefinitionId = ObjectTypeIds.FileDirectoryType, + NodeId = new NodeId("Programs", 2), + BrowseName = new QualifiedName("Programs", 2), + DisplayName = new LocalizedText("Programs") + }; + } + + private static T? Find(FileDirectoryState directory, ISystemContext context, string browseName) + where T : BaseInstanceState + { + return directory.FindChild(context, new QualifiedName(browseName, directory.BrowseName.NamespaceIndex)) as T; + } + + private static uint Open(ISystemContext context, FileState file, byte mode) + { + uint handle = 0; + ServiceResult result = file.Open!.OnCall!(context, file.Open!, file.NodeId, mode, ref handle); + Assert.That(ServiceResult.IsGood(result), Is.True); + return handle; + } + + private static byte[] ReadAll(ISystemContext context, FileState file) + { + uint handle = Open(context, file, 0x1); + ByteString data = ByteString.From([]); + ServiceResult readResult = file.Read!.OnCall!(context, file.Read!, file.NodeId, handle, 1024, ref data); + ServiceResult closeResult = file.Close!.OnCall!(context, file.Close!, file.NodeId, handle); + Assert.That(ServiceResult.IsGood(readResult), Is.True); + Assert.That(ServiceResult.IsGood(closeResult), Is.True); + return data.ToArray(); + } + + private static void WriteAll(ISystemContext context, FileState file, string text) + { + uint handle = Open(context, file, 0x6); + ServiceResult writeResult = file.Write!.OnCall!( + context, file.Write!, file.NodeId, handle, ByteString.From(Encoding.UTF8.GetBytes(text))); + ServiceResult closeResult = file.Close!.OnCall!(context, file.Close!, file.NodeId, handle); + Assert.That(ServiceResult.IsGood(writeResult), Is.True); + Assert.That(ServiceResult.IsGood(closeResult), Is.True); + } + + private static InMemoryFileSystemProvider CreateProvider(bool isWritable = true) + { + return new InMemoryFileSystemProvider(isWritable); + } + + private sealed class TestServerBuilder : IOpcUaServerBuilder + { + public TestServerBuilder(IServiceCollection services) + { + Services = services; + } + + public IServiceCollection Services { get; } + + public IOpcUaServerBuilder AddNodeManager() + where TFactory : class, IAsyncNodeManagerFactory + { + throw new NotSupportedException(); + } + + public IOpcUaServerBuilder AddNodeManager(string namespaceUri, Action build) + { + throw new NotSupportedException(); + } + + public IOpcUaServerBuilder AddSyncNodeManager() + where TFactory : class, INodeManagerFactory + { + throw new NotSupportedException(); + } + } + + private sealed class InMemoryFileSystemProvider : IFileSystemProvider + { + public InMemoryFileSystemProvider(bool isWritable) + { + IsWritable = isWritable; + m_entries[string.Empty] = Entry.Directory("Programs"); + } + + public string MountName => "Programs"; + + public bool IsWritable { get; } + + public int OpenStreamCount + { + get + { + lock (m_lock) + { + return m_openStreamCount; + } + } + } + + public ValueTask GetEntryAsync(string path, CancellationToken ct) + { + lock (m_lock) + { + return new ValueTask( + m_entries.TryGetValue(Normalize(path), out Entry? entry) + ? entry.ToFileSystemEntry(Normalize(path), IsWritable) + : null); + } + } + + public async IAsyncEnumerable EnumerateAsync( + string path, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + await Task.CompletedTask.ConfigureAwait(false); + List entries; + string parent = Normalize(path); + lock (m_lock) + { + if (!m_entries.TryGetValue(parent, out Entry? parentEntry) || !parentEntry.IsDirectory) + { + throw new DirectoryNotFoundException(parent); + } + + entries = m_entries + .Where(kv => IsImmediateChild(parent, kv.Key)) + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => kv.Value.ToFileSystemEntry(kv.Key, IsWritable)) + .ToList(); + } + + foreach (FileSystemEntry entry in entries) + { + ct.ThrowIfCancellationRequested(); + yield return entry; + } + } + + public ValueTask OpenReadAsync(string path, CancellationToken ct) + { + lock (m_lock) + { + string normalized = Normalize(path); + if (!m_entries.TryGetValue(normalized, out Entry? entry) || entry.IsDirectory) + { + throw new FileNotFoundException(normalized); + } + m_openStreamCount++; + return new ValueTask(new TrackingReadStream( + entry.Content.ToArray(), + () => + { + lock (m_lock) + { + m_openStreamCount--; + } + })); + } + } + + public ValueTask OpenWriteAsync(string path, FileWriteMode mode, CancellationToken ct) + { + if (!IsWritable) + { + throw new UnauthorizedAccessException(); + } + + lock (m_lock) + { + string normalized = Normalize(path); + byte[] initial = []; + if (m_entries.TryGetValue(normalized, out Entry? entry)) + { + if (entry.IsDirectory) + { + throw new IOException(normalized); + } + if (mode == FileWriteMode.Append) + { + initial = entry.Content.ToArray(); + } + } + else + { + EnsureParentDirectory(normalized); + m_entries[normalized] = Entry.File([]); + } + + var stream = new CommitMemoryStream(bytes => Commit(normalized, bytes)); + stream.Write(initial, 0, initial.Length); + if (mode == FileWriteMode.Append) + { + stream.Position = stream.Length; + } + else + { + stream.SetLength(0); + } + return new ValueTask(stream); + } + } + + public ValueTask CreateDirectoryAsync(string path, CancellationToken ct) + { + if (!IsWritable) + { + throw new UnauthorizedAccessException(); + } + + lock (m_lock) + { + AddDirectory(Normalize(path)); + } + return default; + } + + public ValueTask CreateFileAsync(string path, CancellationToken ct) + { + if (!IsWritable) + { + throw new UnauthorizedAccessException(); + } + + lock (m_lock) + { + string normalized = Normalize(path); + if (m_entries.ContainsKey(normalized)) + { + throw new IOException(normalized); + } + EnsureParentDirectory(normalized); + m_entries[normalized] = Entry.File([]); + } + return default; + } + + public ValueTask DeleteAsync(string path, CancellationToken ct) + { + if (!IsWritable) + { + throw new UnauthorizedAccessException(); + } + + lock (m_lock) + { + Delete(Normalize(path)); + } + return default; + } + + public ValueTask MoveAsync(string source, string target, CancellationToken ct) + { + if (!IsWritable) + { + throw new UnauthorizedAccessException(); + } + + lock (m_lock) + { + CopyCore(Normalize(source), Normalize(target)); + Delete(Normalize(source)); + } + return default; + } + + public ValueTask CopyAsync(string source, string target, CancellationToken ct) + { + if (!IsWritable) + { + throw new UnauthorizedAccessException(); + } + + lock (m_lock) + { + CopyCore(Normalize(source), Normalize(target)); + } + return default; + } + + public void AddDirectory(string path) + { + string normalized = Normalize(path); + EnsureParentDirectory(normalized); + if (m_entries.TryGetValue(normalized, out Entry? existing) && !existing.IsDirectory) + { + throw new IOException(normalized); + } + m_entries[normalized] = Entry.Directory(NameOf(normalized)); + } + + public void AddFile(string path, string content) + { + string normalized = Normalize(path); + EnsureParentDirectory(normalized); + m_entries[normalized] = Entry.File(Encoding.UTF8.GetBytes(content)); + } + + public bool Exists(string path) + { + lock (m_lock) + { + return m_entries.ContainsKey(Normalize(path)); + } + } + + public string ReadText(string path) + { + lock (m_lock) + { + return Encoding.UTF8.GetString(m_entries[Normalize(path)].Content); + } + } + + public void Delete(string path) + { + string normalized = Normalize(path); + if (string.IsNullOrEmpty(normalized) || !m_entries.ContainsKey(normalized)) + { + throw new FileNotFoundException(normalized); + } + + string prefix = normalized + "/"; + foreach (string key in m_entries.Keys.Where(key => key == normalized || + key.StartsWith(prefix, StringComparison.Ordinal)).ToList()) + { + m_entries.Remove(key); + } + } + + private void Commit(string path, byte[] bytes) + { + lock (m_lock) + { + m_entries[path] = Entry.File(bytes); + } + } + + private void CopyCore(string source, string target) + { + if (!m_entries.TryGetValue(source, out Entry? sourceEntry)) + { + throw new FileNotFoundException(source); + } + if (m_entries.ContainsKey(target)) + { + throw new IOException(target); + } + EnsureParentDirectory(target); + m_entries[target] = sourceEntry.Clone(NameOf(target)); + string sourcePrefix = source + "/"; + foreach (KeyValuePair kv in m_entries.ToArray()) + { + if (kv.Key.StartsWith(sourcePrefix, StringComparison.Ordinal)) + { + string suffix = kv.Key[sourcePrefix.Length..]; + m_entries[target + "/" + suffix] = kv.Value.Clone(NameOf(suffix)); + } + } + } + + private void EnsureParentDirectory(string path) + { + int slash = path.LastIndexOf('/'); + if (slash < 0) + { + return; + } + + string parent = path[..slash]; + if (!m_entries.TryGetValue(parent, out Entry? entry) || !entry.IsDirectory) + { + throw new DirectoryNotFoundException(parent); + } + } + + private static bool IsImmediateChild(string parent, string candidate) + { + if (string.IsNullOrEmpty(candidate)) + { + return false; + } + if (string.IsNullOrEmpty(parent)) + { + return !candidate.Contains('/', StringComparison.Ordinal); + } + if (!candidate.StartsWith(parent + "/", StringComparison.Ordinal)) + { + return false; + } + return !candidate[(parent.Length + 1)..].Contains('/', StringComparison.Ordinal); + } + + private static string NameOf(string path) + { + int slash = path.LastIndexOf('/'); + return slash < 0 ? path : path[(slash + 1)..]; + } + + private static string Normalize(string path) + { + return string.IsNullOrEmpty(path) || path == "/" ? string.Empty : path.Trim('/'); + } + + private readonly Dictionary m_entries = new(StringComparer.Ordinal); + private readonly Lock m_lock = new(); + private int m_openStreamCount; + + private sealed class CommitMemoryStream : MemoryStream + { + public CommitMemoryStream(Action commit) + { + m_commit = commit; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + m_commit(ToArray()); + } + base.Dispose(disposing); + } + + private readonly Action m_commit; + } + + private sealed class TrackingReadStream : MemoryStream + { + public TrackingReadStream(byte[] buffer, Action onDispose) + : base(buffer, writable: false) + { + m_onDispose = onDispose; + } + + protected override void Dispose(bool disposing) + { + if (disposing && !m_disposed) + { + m_disposed = true; + m_onDispose(); + } + base.Dispose(disposing); + } + + private readonly Action m_onDispose; + private bool m_disposed; + } + + private sealed class Entry + { + private Entry(string name, bool isDirectory, byte[] content) + { + Name = name; + IsDirectory = isDirectory; + Content = content; + LastModifiedUtc = DateTime.UtcNow; + } + + public string Name { get; } + + public bool IsDirectory { get; } + + public byte[] Content { get; } + + public DateTime LastModifiedUtc { get; } + + public static Entry Directory(string name) + { + return new Entry(name, isDirectory: true, []); + } + + public static Entry File(byte[] content) + { + return new Entry(string.Empty, isDirectory: false, content); + } + + public Entry Clone(string name) + { + return IsDirectory ? Directory(name) : File(Content.ToArray()); + } + + public FileSystemEntry ToFileSystemEntry(string path, bool isWritable) + { + return new FileSystemEntry( + path, + string.IsNullOrEmpty(path) ? Name : NameOf(path), + IsDirectory, + Content.Length, + isWritable, + LastModifiedUtc, + IsDirectory ? string.Empty : "text/plain"); + } + } + } + } +} diff --git a/tests/Opc.Ua.Server.Tests/Location/InMemoryGeoLocationProviderTests.cs b/tests/Opc.Ua.Server.Tests/Location/InMemoryGeoLocationProviderTests.cs new file mode 100644 index 0000000000..8547d8f485 --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/Location/InMemoryGeoLocationProviderTests.cs @@ -0,0 +1,182 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; + +namespace Opc.Ua.Server.Tests.Location +{ + /// + /// Tests for , the reference + /// implementation of the shared seam. + /// + [TestFixture] + [Category("GeoLocation")] + public class InMemoryGeoLocationProviderTests + { + private const string SourceId = "plant"; + + [Test] + public async Task ReadReturnsTheCurrentSampleAsync() + { + using var provider = new InMemoryGeoLocationProvider(); + provider.Update(SourceId, new GeoPosition(47.0, 8.0)); + + GeoLocationSample sample = await provider + .ReadAsync(SourceId, CancellationToken.None).ConfigureAwait(false); + + Assert.That(sample.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(sample.Position, Is.Not.Null); + Assert.That(sample.Position!.Value.Latitude, Is.EqualTo(47.0)); + Assert.That(sample.Position!.Value.Longitude, Is.EqualTo(8.0)); + } + + [Test] + public void ReadingAnUnknownSourceFails() + { + using var provider = new InMemoryGeoLocationProvider(); + + Assert.That( + async () => await provider + .ReadAsync("missing", CancellationToken.None) + .ConfigureAwait(false), + Throws.TypeOf()); + } + + [Test] + public async Task UpdatesReachWatchersInOrderAsync() + { + using var provider = new InMemoryGeoLocationProvider(); + provider.Update(SourceId, new GeoPosition(47.0, 8.0)); + using var cts = new CancellationTokenSource(); + + await using IAsyncEnumerator enumerator = provider + .WatchAsync(SourceId, cts.Token) + .GetAsyncEnumerator(cts.Token); + + ValueTask first = enumerator.MoveNextAsync(); + provider.Update(SourceId, new GeoPosition(48.0, 8.0)); + Assert.That(await first.ConfigureAwait(false), Is.True); + Assert.That(enumerator.Current.Position!.Value.Latitude, Is.EqualTo(48.0)); + + ValueTask second = enumerator.MoveNextAsync(); + provider.Update(SourceId, new GeoPosition(49.0, 8.0)); + Assert.That(await second.ConfigureAwait(false), Is.True); + Assert.That(enumerator.Current.Position!.Value.Latitude, Is.EqualTo(49.0)); + + await cts.CancelAsync().ConfigureAwait(false); + } + + [Test] + public async Task WatchersOnlySeeTheirOwnSourceAsync() + { + using var provider = new InMemoryGeoLocationProvider(); + provider.Update("a", new GeoPosition(1.0, 1.0)); + provider.Update("b", new GeoPosition(2.0, 2.0)); + using var cts = new CancellationTokenSource(); + + await using IAsyncEnumerator enumerator = provider + .WatchAsync("a", cts.Token) + .GetAsyncEnumerator(cts.Token); + + ValueTask next = enumerator.MoveNextAsync(); + // An update to the other source must not wake this watcher. + provider.Update("b", new GeoPosition(3.0, 3.0)); + provider.Update("a", new GeoPosition(4.0, 4.0)); + + Assert.That(await next.ConfigureAwait(false), Is.True); + Assert.That(enumerator.Current.Position!.Value.Latitude, Is.EqualTo(4.0)); + + await cts.CancelAsync().ConfigureAwait(false); + } + + [Test] + public async Task WatchEndsOnCancellationAsync() + { + using var provider = new InMemoryGeoLocationProvider(); + provider.Update(SourceId, new GeoPosition(47.0, 8.0)); + using var cts = new CancellationTokenSource(); + + var consume = Task.Run(async () => + { + await foreach (GeoLocationSample _ in provider + .WatchAsync(SourceId, cts.Token).ConfigureAwait(false)) + { + // Drain until cancelled. + } + }); + + await cts.CancelAsync().ConfigureAwait(false); + await consume.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + Assert.That(consume.Status, Is.EqualTo(TaskStatus.RanToCompletion)); + } + + [Test] + public void AFaultPropagatesToReaders() + { + using var provider = new InMemoryGeoLocationProvider(); + provider.Update(SourceId, new GeoPosition(47.0, 8.0)); + provider.Fault(SourceId, new InvalidOperationException("boom")); + + Assert.That( + async () => await provider + .ReadAsync(SourceId, CancellationToken.None) + .ConfigureAwait(false), + Throws.InvalidOperationException); + } + + [Test] + public async Task AnUpdateClearsAFaultAsync() + { + using var provider = new InMemoryGeoLocationProvider(); + provider.Update(SourceId, new GeoPosition(47.0, 8.0)); + provider.Fault(SourceId, new InvalidOperationException("boom")); + + provider.Update(SourceId, new GeoPosition(50.0, 8.0)); + GeoLocationSample sample = await provider + .ReadAsync(SourceId, CancellationToken.None).ConfigureAwait(false); + + Assert.That(sample.Position!.Value.Latitude, Is.EqualTo(50.0)); + } + + [Test] + public void AnEmptySourceIdIsRejected() + { + using var provider = new InMemoryGeoLocationProvider(); + + Assert.That( + () => provider.Update(" ", new GeoPosition(47.0, 8.0)), + Throws.ArgumentException); + } + } +} \ No newline at end of file diff --git a/tests/Opc.Ua.Server.Tests/Location/WktGeoLocationTextFormatterTests.cs b/tests/Opc.Ua.Server.Tests/Location/WktGeoLocationTextFormatterTests.cs new file mode 100644 index 0000000000..11bb8b5b48 --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/Location/WktGeoLocationTextFormatterTests.cs @@ -0,0 +1,137 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; + +namespace Opc.Ua.Server.Tests.Location +{ + /// + /// Tests for , which projects a + /// structured sample into the text a location Variable carries. + /// + [TestFixture] + [Category("GeoLocation")] + public class WktGeoLocationTextFormatterTests + { + private static readonly string[] s_building4 = ["Building 4"]; + private static readonly string[] s_detroitPlant = ["Building 4, Detroit Plant"]; + + [Test] + public void APositionWithHeightBecomesAThreeDimensionalPoint() + { + var sample = GeoLocationSample.Good( + new GeoPosition(47.3769, 8.5417, 408.0)); + + ArrayOf literals = WktGeoLocationTextFormatter.Instance + .Format(sample); + + Assert.That(literals.Count, Is.EqualTo(1)); + Assert.That(literals[0], Is.EqualTo("POINT Z (8.5417 47.3769 408)")); + } + + [Test] + public void APositionWithoutHeightBecomesATwoDimensionalPoint() + { + var sample = GeoLocationSample.Good(new GeoPosition(47.3769, 8.5417)); + + ArrayOf literals = WktGeoLocationTextFormatter.Instance + .Format(sample); + + Assert.That(literals.Count, Is.EqualTo(1)); + Assert.That(literals[0], Is.EqualTo("POINT (8.5417 47.3769)")); + } + + [Test] + public void AnEpsgCodeBecomesAnSridPrefix() + { + var sample = GeoLocationSample.Good( + new GeoPosition(47.3769, 8.5417, EpsgCode: 4326)); + + ArrayOf literals = WktGeoLocationTextFormatter.Instance + .Format(sample); + + Assert.That(literals.Count, Is.EqualTo(1)); + Assert.That(literals[0], Is.EqualTo("SRID=4326;POINT (8.5417 47.3769)")); + } + + [Test] + public void CoordinatesUseTheInvariantCulture() + { + var sample = GeoLocationSample.Good(new GeoPosition(-0.5, 1.25)); + + ArrayOf literals = WktGeoLocationTextFormatter.Instance + .Format(sample); + + Assert.That(literals[0], Does.Contain("1.25")); + Assert.That(literals[0], Does.Not.Contain(",")); + } + + [Test] + public void LabelsFollowTheGeometry() + { + var sample = new GeoLocationSample( + new GeoPosition(47.3769, 8.5417), + null, + s_building4.ToArrayOf(), + StatusCodes.Good, + DateTimeUtc.Now); + + ArrayOf literals = WktGeoLocationTextFormatter.Instance + .Format(sample); + + Assert.That(literals.Count, Is.EqualTo(2)); + Assert.That(literals[0], Is.EqualTo("POINT (8.5417 47.3769)")); + Assert.That(literals[1], Is.EqualTo("Building 4")); + } + + [Test] + public void ASampleWithoutAPositionKeepsOnlyItsLabels() + { + var sample = GeoLocationSample.Good( + s_detroitPlant.ToArrayOf()); + + ArrayOf literals = WktGeoLocationTextFormatter.Instance + .Format(sample); + + Assert.That(literals.Count, Is.EqualTo(1)); + Assert.That(literals[0], Is.EqualTo("Building 4, Detroit Plant")); + } + + [Test] + public void AnEmptySampleProducesNoLiterals() + { + var sample = GeoLocationSample.Unavailable(StatusCodes.BadNoDataAvailable); + + ArrayOf literals = WktGeoLocationTextFormatter.Instance + .Format(sample); + + Assert.That(literals.Count, Is.Zero); + } + } +} \ No newline at end of file diff --git a/tests/Opc.Ua.Server.Tests/StateMachines/FiniteStateMachineDispatcherTests.cs b/tests/Opc.Ua.Server.Tests/StateMachines/FiniteStateMachineDispatcherTests.cs new file mode 100644 index 0000000000..6555be740a --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/StateMachines/FiniteStateMachineDispatcherTests.cs @@ -0,0 +1,74 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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 NUnit.Framework; +using Opc.Ua.Server.StateMachines; + +namespace Opc.Ua.Server.Tests.StateMachines +{ + [TestFixture] + [Category("StateMachine")] + public sealed class FiniteStateMachineDispatcherTests + { + private ServerSystemContext m_context = null!; + + [SetUp] + public void SetUp() + { + m_context = StateMachineTestFixtures.CreateContext(); + } + + [Test] + public void MoveWritesCurrentStateAndLastTransition() + { + FluentFiniteStateMachineState machine = StateMachineTestFixtures + .NewBuilder(m_context) + .AddState(1, "Idle", isInitial: true) + .AddState(2, "Ready") + .AddTransition(10, "IdleToReady", from: 1, to: 2) + .StateMachine; + var dispatcher = new FiniteStateMachineDispatcher( + 1, + [new FiniteStateMachineEntry(1, 100, "Idle"), new FiniteStateMachineEntry(2, 200, "Ready")], + [new FiniteStateMachineEntry(10, 300, "IdleToReady")]); + + dispatcher.InitializeToInitialState(machine, 1, m_context); + dispatcher.Move(machine, 2, 10, m_context); + + Assert.That(machine.CurrentState!.Value.Text, Is.EqualTo("Ready")); + Assert.That(machine.CurrentState.Id!.Value, Is.EqualTo(new NodeId(2, 1))); + Assert.That(machine.CurrentState.Number!.Value, Is.EqualTo(200u)); + Assert.That(machine.LastTransition!.Value.Text, Is.EqualTo("IdleToReady")); + Assert.That(machine.LastTransition.Id!.Value, Is.EqualTo(new NodeId(10, 1))); + Assert.That(machine.LastTransition.Number!.Value, Is.EqualTo(300u)); + Assert.That(dispatcher.TryGetCurrentState(machine, out uint stateId), Is.True); + Assert.That(stateId, Is.EqualTo(2u)); + } + } +} diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Dependency/ModelDependencyV1Tests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Dependency/ModelDependencyV1Tests.cs index 7e2619687b..5df4271095 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/Dependency/ModelDependencyV1Tests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Dependency/ModelDependencyV1Tests.cs @@ -121,6 +121,36 @@ public void WriteThenRead_PreservesUnicodeBrowseNames() Assert.That(decoded.Nodes[0].ClassName, Is.EqualTo("GrรถรŸentyp")); } + [TestCase(true)] + [TestCase(false)] + public void WriteThenRead_RoundTripsFluentAccessorCapability(bool emitted) + { + var dependency = new ModelDependencyV1 + { + ModelUri = "http://example.org/UA/Capabilities/", + FluentAccessorsEmitted = emitted + }; + + var decoded = ModelDependencyV1.FromBase64Payload(dependency.ToBase64Payload()); + + Assert.That(decoded, Is.Not.Null); + Assert.That(decoded.FluentAccessorsEmitted, Is.EqualTo(emitted)); + } + + [Test] + public void WriteThenRead_OmittedCapabilityRemainsUnknown() + { + var dependency = new ModelDependencyV1 + { + ModelUri = "http://example.org/UA/Legacy/" + }; + + var decoded = ModelDependencyV1.FromBase64Payload(dependency.ToBase64Payload()); + + Assert.That(decoded, Is.Not.Null); + Assert.That(decoded.FluentAccessorsEmitted, Is.Null); + } + [Test] public void Read_ReturnsNullForWrongMagic() { diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/ModelDependencyGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/ModelDependencyGeneratorTests.cs index 2f54929bae..e853b4523a 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/ModelDependencyGeneratorTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/ModelDependencyGeneratorTests.cs @@ -30,10 +30,13 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; +using System.Xml; using Moq; using NUnit.Framework; using Opc.Ua.Schema.Model; +using Opc.Ua.SourceGeneration.Dependency; namespace Opc.Ua.SourceGeneration.Generator.Tests { @@ -265,6 +268,136 @@ public void Emit_QuoteCharacterInValue_IsEscaped() Assert.That(output, Does.Contain("\"v\\\"1\\\"\"")); } + [Test] + public void EmitDeclarationBackedMethodSerializesEffectiveArguments() + { + Namespace target = ConfigureSelf(); + const string opcUaNamespace = Types.Namespaces.OpcUa; + var declaration = new MethodDesign + { + SymbolicId = new XmlQualifiedName("ExecuteMethodType", TestUri), + SymbolicName = new XmlQualifiedName("ExecuteMethodType", TestUri), + NumericId = 42, + NumericIdSpecified = true, + InputArguments = + [ + new Parameter + { + Name = "Name", + DataType = new XmlQualifiedName("String", opcUaNamespace), + ValueRank = ValueRank.Scalar + } + ], + OutputArguments = + [ + new Parameter + { + Name = "Status", + DataType = new XmlQualifiedName("Int16", opcUaNamespace), + ValueRank = ValueRank.Scalar + } + ] + }; + var method = new MethodDesign + { + BrowseName = "Execute", + SymbolicId = new XmlQualifiedName("ControllerType_Execute", TestUri), + SymbolicName = new XmlQualifiedName("Execute", TestUri), + MethodDeclarationNode = declaration, + InputArguments = [], + OutputArguments = [] + }; + var objectType = new ObjectTypeDesign + { + ClassName = "Controller", + SymbolicId = new XmlQualifiedName("ControllerType", TestUri), + SymbolicName = new XmlQualifiedName("ControllerType", TestUri), + HasChildren = true, + Children = new ListOfChildren { Items = [method] } + }; + m_mockModelDesign.Setup(m => m.TargetNamespace).Returns(target); + m_mockModelDesign.Setup(m => m.Nodes).Returns([objectType]); + + var generator = new ModelDependencyGenerator(BuildContext()); + generator.Emit(); + + string output = ReadOutput(); + int payloadEnd = output.IndexOf("\")]", StringComparison.Ordinal); + Assert.That(payloadEnd, Is.GreaterThanOrEqualTo(0)); + int payloadStart = output.LastIndexOf('"', payloadEnd - 1); + Assert.That(payloadStart, Is.GreaterThanOrEqualTo(0)); + string encodedPayload = output[(payloadStart + 1)..payloadEnd]; + ModelDependencyV1 payload = + ModelDependencyV1.FromBase64Payload(encodedPayload); + Assert.That(payload, Is.Not.Null); + DependencyChild child = payload.Nodes + .Single(node => node.SymbolicName == "ControllerType") + .Children + .Single(candidate => candidate.SymbolicName == "Execute"); + + Assert.Multiple(() => + { + Assert.That(child.InputArguments, Has.Count.EqualTo(1)); + Assert.That(child.InputArguments[0].Name, Is.EqualTo("Name")); + Assert.That(child.InputArguments[0].DataTypeName, Is.EqualTo("String")); + Assert.That(child.OutputArguments, Has.Count.EqualTo(1)); + Assert.That(child.OutputArguments[0].Name, Is.EqualTo("Status")); + Assert.That(child.OutputArguments[0].DataTypeName, Is.EqualTo("Int16")); + Assert.That(child.MethodStateName, Is.EqualTo("ExecuteMethodType")); + Assert.That(child.MethodStateNamespace, Is.EqualTo(TestUri)); + Assert.That(child.MethodDeclarationName, Is.EqualTo("ExecuteMethodType")); + Assert.That(child.MethodDeclarationNamespace, Is.EqualTo(TestUri)); + Assert.That(child.MethodDeclarationNumericId, Is.EqualTo(42)); + }); + } + + [Test] + public void LegacyPayloadWithoutMethodIdentityRemainsReadable() + { + var payload = new ModelDependencyV1 + { + ModelUri = TestUri, + FluentAccessorsEmitted = false + }; + payload.Nodes.Add(new DependencyNode + { + SymbolicName = "ControllerType", + SymbolicNamespace = TestUri, + ClassName = "Controller", + Kind = DependencyNodeKind.ObjectType, + Children = + [ + new DependencyChild + { + BrowseName = "Execute", + SymbolicName = "Execute", + InstanceKind = 4, + InputArguments = + [ + new DependencyMethodArg( + "Name", + "String", + Types.Namespaces.OpcUa, + (int)ValueRank.Scalar) + ] + } + ] + }); + + ModelDependencyV1 decoded = + ModelDependencyV1.FromBase64Payload(payload.ToBase64Payload()); + + Assert.That(decoded, Is.Not.Null); + DependencyChild child = decoded.Nodes.Single().Children.Single(); + Assert.Multiple(() => + { + Assert.That(decoded.FluentAccessorsEmitted, Is.False); + Assert.That(child.InputArguments, Has.Count.EqualTo(1)); + Assert.That(child.MethodStateName, Is.Empty); + Assert.That(child.MethodDeclarationName, Is.Empty); + }); + } + private Namespace ConfigureSelf( string version = "1.05.04", string publicationDate = "2024-05-01T00:00:00Z") diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeStateGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeStateGeneratorTests.cs index b728662371..cef387d08b 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeStateGeneratorTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeStateGeneratorTests.cs @@ -31,14 +31,17 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Text; using System.Text.RegularExpressions; +using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; using Opc.Ua.Schema.Model; +using Opc.Ua.SourceGeneration.Api.Tests; using Opc.Ua.Tests; namespace Opc.Ua.SourceGeneration.Generator.Tests @@ -107,6 +110,38 @@ public void Constructor_ValidContext_CreatesInstance() Assert.That(generator, Is.Not.Null); } + [TestCase(ModellingRule.Mandatory, true)] + [TestCase(ModellingRule.Optional, true)] + [TestCase(ModellingRule.None, false)] + [TestCase(ModellingRule.OptionalPlaceholder, false)] + public void HasFixedChildSlotRequiresFixedModellingRule( + ModellingRule modellingRule, + bool expected) + { + var type = new ObjectTypeDesign + { + Children = new ListOfChildren + { + Items = + [ + new VariableDesign + { + SymbolicName = new System.Xml.XmlQualifiedName( + "DefaultInstanceBrowseName", + Types.Namespaces.OpcUa), + ModellingRule = modellingRule + } + ] + } + }; + + Assert.That( + NodeStateGenerator.HasFixedChildSlot( + type, + "DefaultInstanceBrowseName"), + Is.EqualTo(expected)); + } + /// /// Tests that Emit returns early without creating files when no node state classes exist. /// @@ -205,6 +240,306 @@ public void GeneratedNodeStateGeneratorCodeCompilesTest() $"Generated NodeStates should compile without errors. Errors: {errorCount}, Warnings: {warnCount}"); } + [Test] + public void NodeSetMethodArgumentsGenerateTypedDelegatesResultsAndPlumbing() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); + IModelDesign model = OpenNodeSetModel( + "TypedMethodArguments.NodeSet2.xml", + telemetry); + ObjectTypeDesign controllerDesign = model.Nodes + .OfType() + .Single(type => type.SymbolicId.Name == "ControllerType"); + MethodDesign executeDesign = controllerDesign.Children.Items + .OfType() + .Single(method => method.BrowseName == "Execute"); + MethodDesign calibrateDesign = controllerDesign.Children.Items + .OfType() + .Single(method => method.BrowseName == "Calibrate"); + Assert.Multiple(() => + { + Assert.That( + MethodDesignArgumentResolver.ResolveMethodInputs(executeDesign), + Has.Length.EqualTo(4)); + Assert.That( + MethodDesignArgumentResolver.ResolveMethodOutputs(executeDesign), + Has.Length.EqualTo(4)); + Assert.That( + MethodDesignArgumentResolver.ResolveMethodInputs(calibrateDesign), + Has.Length.EqualTo(1)); + Assert.That( + MethodDesignArgumentResolver.ResolveMethodOutputs(calibrateDesign), + Has.Length.EqualTo(1)); + }); + + Dictionary files = GenerateFromNodeSet( + "TypedMethodArguments.NodeSet2.xml", + telemetry); + string nodeStates = files.Single( + kv => kv.Key.EndsWith(".NodeStates.g.cs", StringComparison.Ordinal)).Value; + + Assert.Multiple(() => + { + Assert.That(nodeStates, Does.Contain( + "_inputArguments[0].TryGetValue(out string name);")); + Assert.That(nodeStates, Does.Contain( + "_inputArguments[1].TryGetValue(out global::Opc.Ua.NodeId targetId);")); + Assert.That(nodeStates, Does.Contain( + "_inputArguments[2].TryGetValue(out short mode);")); + Assert.That(nodeStates, Does.Contain( + "_inputArguments[3].TryGetValue(out " + + "global::Opc.Ua.ArrayOf targets);")); + Assert.That(nodeStates, Does.Contain( + "_outputArguments[0] = global::Opc.Ua.Variant.From(_result.Status);")); + Assert.That(nodeStates, Does.Contain( + "_outputArguments[3] = global::Opc.Ua.Variant.From(_result.RelatedIds);")); + Assert.That(nodeStates, Does.Not.Contain("class PingMethodState")); + }); + + Assembly assembly = CompileGeneratedAssembly(files); + Type executeDelegate = GetGeneratedType( + assembly, + "ExecuteMethodStateMethodCallHandler"); + ParameterInfo[] executeParameters = + executeDelegate.GetMethod("Invoke")!.GetParameters(); + + Assert.Multiple(() => + { + Assert.That(executeParameters, Has.Length.EqualTo(11)); + Assert.That(executeParameters[3].ParameterType, Is.EqualTo(typeof(string))); + Assert.That(executeParameters[4].ParameterType.FullName, Is.EqualTo("Opc.Ua.NodeId")); + Assert.That(executeParameters[5].ParameterType, Is.EqualTo(typeof(short))); + AssertArrayOfNodeIds(executeParameters[6].ParameterType); + Assert.That(executeParameters[7].ParameterType, Is.EqualTo(typeof(short).MakeByRefType())); + Assert.That( + executeParameters[8].ParameterType.GetElementType()?.FullName, + Is.EqualTo("Opc.Ua.NodeId")); + Assert.That( + executeParameters[9].ParameterType, + Is.EqualTo(typeof(string).MakeByRefType())); + AssertArrayOfNodeIds(executeParameters[10].ParameterType.GetElementType()); + }); + + Type asyncDelegate = GetGeneratedType( + assembly, + "ExecuteMethodStateMethodAsyncCallHandler"); + MethodInfo asyncInvoke = asyncDelegate.GetMethod("Invoke")!; + Assert.Multiple(() => + { + Assert.That(asyncInvoke.GetParameters(), Has.Length.EqualTo(8)); + Assert.That(asyncInvoke.ReturnType.IsGenericType, Is.True); + Assert.That( + asyncInvoke.ReturnType.GetGenericTypeDefinition(), + Is.EqualTo(typeof(ValueTask<>))); + Assert.That( + asyncInvoke.ReturnType.GetGenericArguments()[0].Name, + Is.EqualTo("ExecuteMethodStateResult")); + }); + + Type result = GetGeneratedType(assembly, "ExecuteMethodStateResult"); + Assert.Multiple(() => + { + Assert.That(result.GetProperty("ServiceResult"), Is.Not.Null); + Assert.That(result.GetProperty("Status")?.PropertyType, Is.EqualTo(typeof(short))); + Assert.That( + result.GetProperty("SelectedId")?.PropertyType.FullName, + Is.EqualTo("Opc.Ua.NodeId")); + Assert.That(result.GetProperty("Message")?.PropertyType, Is.EqualTo(typeof(string))); + AssertArrayOfNodeIds(result.GetProperty("RelatedIds")?.PropertyType); + }); + + Type controller = GetGeneratedType(assembly, "ControllerState"); + Assert.Multiple(() => + { + Assert.That( + controller.GetProperty("Calibrate")?.PropertyType.Name, + Is.EqualTo("SharedCalibrationMethodState")); + Assert.That( + controller.GetProperty("Ping")?.PropertyType.FullName, + Is.EqualTo("Opc.Ua.MethodState")); + }); + } + + [Test] + public void DuplicateMethodNamesReuseOnlyMatchingSignatures() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); + IModelDesign focusedModel = OpenNodeSetModel( + "DuplicateMethodNames.NodeSet2.xml", + telemetry); + MethodDesign alpha = GetMethod(focusedModel, "AlphaType", "Execute"); + MethodDesign beta = GetMethod(focusedModel, "BetaType", "Execute"); + MethodDesign gamma = GetMethod(focusedModel, "GammaType", "Execute"); + + Assert.Multiple(() => + { + Assert.That(alpha.MethodDeclarationNode, Is.SameAs(beta.MethodDeclarationNode)); + Assert.That(gamma.MethodDeclarationNode, Is.Not.SameAs(alpha.MethodDeclarationNode)); + Assert.That( + alpha.MethodDeclarationNode.SymbolicId.Name, + Is.EqualTo("ExecuteAlphaTypeMethodType")); + Assert.That( + gamma.MethodDeclarationNode.SymbolicId.Name, + Is.EqualTo("ExecuteGammaTypeMethodType")); + }); + + Dictionary files = GenerateFromNodeSet( + "DuplicateMethodNames.NodeSet2.xml", + telemetry); + Assembly assembly = CompileGeneratedAssembly(files); + Type sharedDelegate = GetGeneratedType( + assembly, + "ExecuteAlphaTypeMethodStateMethodCallHandler"); + Type stringDelegate = GetGeneratedType( + assembly, + "ExecuteGammaTypeMethodStateMethodCallHandler"); + Type alphaState = GetGeneratedType(assembly, "AlphaState"); + Type betaState = GetGeneratedType(assembly, "BetaState"); + Type gammaState = GetGeneratedType(assembly, "GammaState"); + + Assert.Multiple(() => + { + Assert.That( + sharedDelegate.GetMethod("Invoke")!.GetParameters()[3].ParameterType, + Is.EqualTo(typeof(int))); + Assert.That( + stringDelegate.GetMethod("Invoke")!.GetParameters()[3].ParameterType, + Is.EqualTo(typeof(string))); + Assert.That( + alphaState.GetProperty("Execute")?.PropertyType.Name, + Is.EqualTo("ExecuteAlphaTypeMethodState")); + Assert.That( + betaState.GetProperty("Execute")?.PropertyType.Name, + Is.EqualTo("ExecuteAlphaTypeMethodState")); + Assert.That( + gammaState.GetProperty("Execute")?.PropertyType.Name, + Is.EqualTo("ExecuteGammaTypeMethodState")); + }); + } + + [Test] + public void DiGetUpdateBehaviorMethodsUseDistinctDeclarations() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); + IModelDesign model = OpenNodeSetModel("Opc.Ua.Di.NodeSet2.xml", telemetry); + MethodDesign cached = GetMethod(model, "CachedLoadingType", "GetUpdateBehavior"); + MethodDesign fileSystem = GetMethod( + model, + "FileSystemLoadingType", + "GetUpdateBehavior"); + + Assert.Multiple(() => + { + Assert.That(cached.MethodDeclarationNode, Is.Not.Null); + Assert.That(fileSystem.MethodDeclarationNode, Is.Not.Null); + Assert.That( + cached.MethodDeclarationNode, + Is.Not.SameAs(fileSystem.MethodDeclarationNode)); + Assert.That( + cached.MethodDeclarationNode.SymbolicId.Name, + Is.EqualTo("GetUpdateBehaviorCachedLoadingTypeMethodType")); + Assert.That( + fileSystem.MethodDeclarationNode.SymbolicId.Name, + Is.EqualTo("GetUpdateBehaviorFileSystemLoadingTypeMethodType")); + Assert.That( + MethodDesignArgumentResolver.ResolveMethodInputs(cached), + Has.Length.EqualTo(3)); + Assert.That( + MethodDesignArgumentResolver.ResolveMethodInputs(fileSystem), + Has.Length.EqualTo(1)); + }); + } + + [Test] + public void DerivedMethodTypeOverrideUsesStringDefinitionEverywhere() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); + IModelDesign model = OpenModelDesign( + "MethodTypeOverride.ModelDesign.xml", + telemetry); + ObjectTypeDesign derivedType = model.Nodes + .OfType() + .Single(type => type.SymbolicId.Name == "StringControllerType"); + MethodDesign stringMethodType = model.Nodes + .OfType() + .Single(method => method.SymbolicId.Name == "StringConvertMethodType"); + Assert.That(stringMethodType.InputArguments, Has.Length.EqualTo(1)); + Assert.That(stringMethodType.OutputArguments, Has.Length.EqualTo(1)); + MethodDesign mergedMethod = (MethodDesign)derivedType.Hierarchy.Nodes["Convert"].Instance; + Parameter[] inputs = + MethodDesignArgumentResolver.ResolveMethodInputs(mergedMethod); + Parameter[] outputs = + MethodDesignArgumentResolver.ResolveMethodOutputs(mergedMethod); + Assert.That(inputs, Has.Length.EqualTo(1)); + Assert.That(outputs, Has.Length.EqualTo(1)); + + Assert.Multiple(() => + { + Assert.That( + mergedMethod.TypeDefinition.Name, + Is.EqualTo("StringConvertMethodType")); + Assert.That( + mergedMethod.MethodType.SymbolicId.Name, + Is.EqualTo("StringConvertMethodType")); + Assert.That( + mergedMethod.MethodDeclarationNode.SymbolicId.Name, + Is.EqualTo("StringControllerType_Convert")); + Assert.That( + mergedMethod.MethodDeclarationNode, + Is.SameAs(mergedMethod)); + Assert.That(mergedMethod.NumericIdSpecified, Is.True); + Assert.That( + inputs[0].DataType.Name, + Is.EqualTo("String")); + Assert.That( + outputs[0].DataType.Name, + Is.EqualTo("String")); + }); + + Dictionary files = GenerateFromModelDesign( + "MethodTypeOverride.ModelDesign.xml", + telemetry); + string extensions = files.Single( + file => file.Key.EndsWith(".NodeStates.ex.g.cs", StringComparison.Ordinal)).Value; + string factory = ExtractMethodBody( + extensions, + "CreateStringControllerType_Convert"); + Assert.Multiple(() => + { + Assert.That( + factory, + Does.Contain("global::MethodTypeOverride.StringConvertMethodState")); + Assert.That( + factory, + Does.Contain( + $"state.MethodDeclarationId = global::Opc.Ua.NodeId.Create({mergedMethod.NumericId}u")); + Assert.That( + factory, + Does.Not.Contain("global::MethodTypeOverride.IntegerConvertMethodState")); + }); + + Assembly assembly = CompileGeneratedAssembly(files); + Type derivedState = GetGeneratedType(assembly, "StringControllerState"); + Type handler = GetGeneratedType( + assembly, + "StringConvertMethodStateMethodCallHandler"); + ParameterInfo[] parameters = handler.GetMethod("Invoke")!.GetParameters(); + Assert.Multiple(() => + { + Assert.That( + derivedState.GetProperty( + "Convert", + BindingFlags.Public | + BindingFlags.Instance | + BindingFlags.DeclaredOnly)?.PropertyType.Name, + Is.EqualTo("StringConvertMethodState")); + Assert.That(parameters[3].ParameterType, Is.EqualTo(typeof(string))); + Assert.That( + parameters[4].ParameterType, + Is.EqualTo(typeof(string).MakeByRefType())); + }); + } + [Test] public void NodeStateGeneratorCodeGeneratesCorrectMethodSignatures() { @@ -526,6 +861,148 @@ private static Dictionary GenerateFromNodeSet( .ToDictionary(c => c, c => Encoding.UTF8.GetString(fileSystem.Get(c))); } + private static Dictionary GenerateFromModelDesign( + string modelDesignResource, + ITelemetryContext telemetry) + { + using var fileSystem = new VirtualFileSystem(); + string path = Path.Combine( + Directory.GetCurrentDirectory(), + "Resources", + modelDesignResource); + Generators.GenerateCode( + new DesignFileCollection + { + Targets = [path], + Options = new DesignFileOptions() + }, + fileSystem, + string.Empty, + telemetry, + new GeneratorOptions { OmitFluentApi = true }); + + return fileSystem.CreatedFiles + .Where(c => Path.GetExtension(c) == ".cs") + .ToDictionary(c => c, c => Encoding.UTF8.GetString(fileSystem.Get(c))); + } + + private static IModelDesign OpenNodeSetModel( + string nodeSetResource, + ITelemetryContext telemetry) + { + using var fileSystem = new VirtualFileSystem(); + string path = Path.Combine( + Directory.GetCurrentDirectory(), "Resources", nodeSetResource); + var nodesets = new NodesetFileCollection( + [(path, new NodesetFileOptions())], + [], + fileSystem, + telemetry); + string modelUri = nodesets.ModelUris.Single(); + List designFiles = nodesets.GetDesignFileListForModel( + modelUri, + out _); + IFileSystem sourceFileSystem = typeof(Generators).Assembly + .AsFileSystem("Opc.Ua.SourceGeneration.Design") + .WithFallback(fileSystem); + return sourceFileSystem.OpenModelDesign( + new DesignFileCollection { Targets = designFiles }, + [], + telemetry, + useAllowSubtypes: false); + } + + private static IModelDesign OpenModelDesign( + string modelDesignResource, + ITelemetryContext telemetry) + { + using var fileSystem = new VirtualFileSystem(); + string path = Path.Combine( + Directory.GetCurrentDirectory(), + "Resources", + modelDesignResource); + IFileSystem sourceFileSystem = typeof(Generators).Assembly + .AsFileSystem("Opc.Ua.SourceGeneration.Design") + .WithFallback(fileSystem); + return sourceFileSystem.OpenModelDesign( + new DesignFileCollection { Targets = [path] }, + [], + telemetry, + useAllowSubtypes: false); + } + + private static MethodDesign GetMethod( + IModelDesign model, + string ownerName, + string browseName) + { + ObjectTypeDesign owner = model.Nodes + .OfType() + .Single(type => type.SymbolicId.Name == ownerName); + return owner.Children.Items + .OfType() + .Single(method => method.BrowseName == browseName); + } + + private static Assembly CompileGeneratedAssembly( + Dictionary files) + { + using var peStream = new MemoryStream(); + ITelemetryContext telemetry = NUnitTelemetryContext.Create(logLevel: LogLevel.Error); + Dictionary stackFiles = GenerateStackTests.GenerateStack( + StackGenerationType.Models, + telemetry, + out _); + IEnumerable> generatedNodeStateFiles = stackFiles + .Concat(files) + .Where(file => !file.Key.EndsWith( + ".TypeProxies.g.cs", + StringComparison.Ordinal)); + bool success = OptimizationLevel.Debug + .CreateCompilation("TypedMethodArguments.Generated") + .AddCode( + generatedNodeStateFiles.WithOpcUaCoreStubs(), + LanguageVersion.Latest) + .Emit(peStream) + .Check(TestContext.Out, out int errorCount, out int warningCount); + + Assert.That( + success, + Is.True, + $"Generated code failed with {errorCount} errors and {warningCount} warnings."); + return Assembly.Load(peStream.ToArray()); + } + + private static Type GetGeneratedType(Assembly assembly, string name) + { + Type type = assembly.GetTypes().SingleOrDefault(candidate => candidate.Name == name); + Assert.That(type, Is.Not.Null, $"Generated type '{name}' was not found."); + return type; + } + + private static void AssertArrayOfNodeIds(Type type) + { + Assert.That(type, Is.Not.Null); + if (type == null) + { + return; + } + Assert.That(type.IsGenericType, Is.True); + if (!type.IsGenericType) + { + return; + } + Assert.Multiple(() => + { + Assert.That( + type.GetGenericTypeDefinition().FullName, + Is.EqualTo("Opc.Ua.ArrayOf`1")); + Assert.That( + type.GetGenericArguments()[0].FullName, + Is.EqualTo("Opc.Ua.NodeId")); + }); + } + /// /// Extracts the source of the factory method whose signature begins /// with internal static โ€ฆ {methodName}(. Returns the body from diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/StateMachineIdsGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/StateMachineIdsGeneratorTests.cs index 1df963987a..9dfbc73cc7 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/StateMachineIdsGeneratorTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/StateMachineIdsGeneratorTests.cs @@ -29,6 +29,8 @@ using System; using System.Linq; +using System.Text; +using System.Xml; using Moq; using NUnit.Framework; using Opc.Ua.Schema.Model; @@ -104,5 +106,120 @@ public void Emit_ModelWithoutFsmSubtypes_ReturnsEmpty() Assert.That(resources, Is.Empty); mockFileSystem.VerifyNoOtherCalls(); } + + [Test] + public void Emit_NodeSetStyleNumberVariables_EmitsStateAndTransitionNumbers() + { + const string testNamespaceUri = "http://test.org/UA/"; + const string uaNamespaceUri = "http://opcfoundation.org/UA/"; + + var finiteStateMachineType = new ObjectTypeDesign + { + SymbolicName = new XmlQualifiedName("FiniteStateMachineType", uaNamespaceUri) + }; + var machineType = new ObjectTypeDesign + { + SymbolicId = new XmlQualifiedName("TestStateMachineType", testNamespaceUri), + SymbolicName = new XmlQualifiedName("TestStateMachineType", testNamespaceUri), + BrowseName = "TestStateMachineType", + BaseTypeNode = finiteStateMachineType, + Children = new ListOfChildren + { + Items = + [ + CreateState("Idle", 1), + CreateTransition("IdleToReady", 101) + ] + }, + HasChildren = true + }; + + var targetNamespace = new Namespace + { + Value = testNamespaceUri, + Prefix = "Test", + Name = "TestNamespace" + }; + + var mockModelDesign = new Mock(); + mockModelDesign.Setup(m => m.TargetNamespace).Returns(targetNamespace); + mockModelDesign.Setup(m => m.GetNodeDesigns()).Returns([machineType]); + mockModelDesign.Setup(m => m.IsExcluded(It.IsAny())).Returns(false); + + using var fileSystem = new VirtualFileSystem(); + var mockTelemetry = new Mock(); + var context = new GeneratorContext + { + FileSystem = fileSystem, + OutputFolder = "out", + ModelDesign = mockModelDesign.Object, + Telemetry = mockTelemetry.Object, + Options = new GeneratorOptions() + }; + + var generator = new StateMachineIdsGenerator(context); + + var resources = generator.Emit().ToList(); + + Assert.That(resources, Is.Not.Empty); + string output = Encoding.UTF8.GetString(fileSystem.Get( + System.IO.Path.Combine("out", "Test.StateMachineIds.g.cs"))); + Assert.That(output, Does.Contain("public const uint Idle = 1u;")); + Assert.That(output, Does.Contain("public const uint IdleToReady = 101u;")); + } + + private static ObjectDesign CreateState(string name, uint number) + { + return CreateStateMachineChild(name, "StateType", "StateNumber", number); + } + + private static ObjectDesign CreateTransition(string name, uint number) + { + return CreateStateMachineChild(name, "TransitionType", "TransitionNumber", number); + } + + private static ObjectDesign CreateStateMachineChild( + string name, + string typeDefinition, + string numberPropertyName, + uint number) + { + const string uaNamespaceUri = "http://opcfoundation.org/UA/"; + const string testNamespaceUri = "http://test.org/UA/"; + + return new ObjectDesign + { + SymbolicName = new XmlQualifiedName(name, testNamespaceUri), + BrowseName = name, + TypeDefinition = new XmlQualifiedName(typeDefinition, uaNamespaceUri), + Children = new ListOfChildren + { + Items = + [ + new VariableDesign + { + SymbolicName = new XmlQualifiedName(numberPropertyName, testNamespaceUri), + BrowseName = numberPropertyName, + DecodedValue = number, + DefaultValue = CreateUInt32Value(number) + } + ] + }, + HasChildren = true + }; + } + + private static System.Xml.XmlElement CreateUInt32Value(uint number) + { + var document = new XmlDocument(); + System.Xml.XmlElement value = document.CreateElement("Value"); + System.Xml.XmlElement child = document.CreateElement( + "uax", + "UInt32", + "http://opcfoundation.org/UA/2008/02/Types.xsd"); + child.InnerText = number.ToString(System.Globalization.CultureInfo.InvariantCulture); + value.AppendChild(child); + return value; + } } } diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/GeneratorsTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/GeneratorsTests.cs index 686968bfc0..12911b5ad2 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/GeneratorsTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/GeneratorsTests.cs @@ -27,6 +27,9 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System; +using System.Collections.Generic; +using System.Reflection; using Moq; using NUnit.Framework; @@ -314,5 +317,66 @@ public void GenerateCode_BooleanParameterCombinations_DoesNotThrow( mockTelemetry.Object, useAllowSubtypes: useAllowSubtypes)); } + + /// + /// Verifies that the original public GenerateCode signatures remain available. + /// + [Test] + public void GenerateCodeOriginalPublicSignaturesArePreserved() + { + AssertPublicGenerateCodeSignature( + [ + typeof(DesignFileCollection), + typeof(IFileSystem), + typeof(string), + typeof(ITelemetryContext), + typeof(GeneratorOptions), + typeof(bool), + typeof(List), + typeof(IReadOnlyDictionary), + typeof(IReadOnlyList), + typeof(Action), + typeof(HashSet), + typeof(int) + ]); + AssertPublicGenerateCodeSignature( + [ + typeof(NodesetFileCollection), + typeof(IFileSystem), + typeof(string), + typeof(ITelemetryContext), + typeof(GeneratorOptions), + typeof(bool), + typeof(IReadOnlyDictionary), + typeof(IReadOnlyList), + typeof(Action), + typeof(IReadOnlyDictionary), + typeof(HashSet), + typeof(int) + ]); + } + + private static void AssertPublicGenerateCodeSignature(Type[] parameterTypes) + { + MethodInfo method = typeof(Generators).GetMethod( + nameof(Generators.GenerateCode), + parameterTypes) ?? throw new AssertionException("GenerateCode signature not found."); + + Assert.Multiple(() => + { + Assert.That(method.IsPublic, Is.True); + Assert.That(method.IsStatic, Is.True); + Assert.That(method.ReturnType, Is.EqualTo(typeof(void))); + }); + + ParameterInfo[] parameters = method.GetParameters(); + for (int ii = 4; ii < parameters.Length; ii++) + { + Assert.That( + parameters[ii].IsOptional, + Is.True, + $"Parameter '{parameters[ii].Name}' must remain optional."); + } + } } } diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/DeclarationBackedMethodConsumer.NodeSet2.xml b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/DeclarationBackedMethodConsumer.NodeSet2.xml new file mode 100644 index 0000000000..d37ceae97f --- /dev/null +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/DeclarationBackedMethodConsumer.NodeSet2.xml @@ -0,0 +1,39 @@ + + + + http://test.org/UA/DeclarationBackedMethodConsumer/ + http://test.org/UA/DeclarationBackedMethod/ + + + + + + + + + i=47 + i=37 + i=45 + + + + DerivedControllerType + + ns=2;i=1000 + ns=1;i=3001 + + + + + Adjust + + i=78 + + + diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/DuplicateMethodNames.NodeSet2.xml b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/DuplicateMethodNames.NodeSet2.xml new file mode 100644 index 0000000000..c7fd1b30ad --- /dev/null +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/DuplicateMethodNames.NodeSet2.xml @@ -0,0 +1,266 @@ + + + + http://test.org/UA/DuplicateMethodNames/ + + + + + + + + i=296 + i=47 + i=37 + i=46 + i=45 + i=40 + i=6 + i=12 + + + + AlphaType + + i=58 + ns=1;i=1001 + + + + Execute + + i=78 + ns=1;i=1002 + ns=1;i=1003 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Value + + i=6 + + -1 + + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Result + + i=6 + + -1 + + + + + + + + + + + BetaType + + i=58 + ns=1;i=2001 + + + + Execute + + i=78 + ns=1;i=2002 + ns=1;i=2003 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Value + + i=6 + + -1 + + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Result + + i=6 + + -1 + + + + + + + + + + + GammaType + + i=58 + ns=1;i=3001 + + + + Execute + + i=78 + ns=1;i=3002 + ns=1;i=3003 + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Value + + i=12 + + -1 + + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Result + + i=12 + + -1 + + + + + + + + + diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/MethodTypeOverride.ModelDesign.xml b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/MethodTypeOverride.ModelDesign.xml new file mode 100644 index 0000000000..9cfe91996b --- /dev/null +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/MethodTypeOverride.ModelDesign.xml @@ -0,0 +1,58 @@ + + + + http://test.org/UA/MethodTypeOverride/ + http://opcfoundation.org/UA/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TypedMethodArguments.NodeSet2.xml b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TypedMethodArguments.NodeSet2.xml new file mode 100644 index 0000000000..c06a7073f0 --- /dev/null +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/TypedMethodArguments.NodeSet2.xml @@ -0,0 +1,303 @@ + + + + http://test.org/UA/TypedMethodArguments/ + + + + + + + + i=296 + i=47 + i=37 + i=46 + i=45 + i=40 + i=4 + i=17 + i=12 + + + + ControllerType + + i=58 + ns=1;i=1001 + ns=1;i=1002 + ns=1;i=1003 + + + + + Execute + + i=78 + ns=1;i=1101 + ns=1;i=1102 + + + + + Ping + + i=78 + + + + + Calibrate + + i=78 + + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Name + + i=12 + + -1 + + + + + + + + i=297 + + + + TargetId + + i=17 + + -1 + + + + + + + + i=297 + + + + Mode + + i=4 + + -1 + + + + + + + + i=297 + + + + Targets + + i=17 + + 1 + + 0 + + + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Status + + i=4 + + -1 + + + + + + + + i=297 + + + + SelectedId + + i=17 + + -1 + + + + + + + + i=297 + + + + Message + + i=12 + + -1 + + + + + + + + i=297 + + + + RelatedIds + + i=17 + + 1 + + 0 + + + + + + + + + + + SharedCalibration + + ns=1;i=1201 + ns=1;i=1202 + + + + + InputArguments + + i=78 + i=68 + + + + + + i=297 + + + + Offset + + i=4 + + -1 + + + + + + + + + + + OutputArguments + + i=78 + i=68 + + + + + + i=297 + + + + CalibrationNode + + i=17 + + -1 + + + + + + + + + diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/VendorRobotics.NodeSet2.xml b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/VendorRobotics.NodeSet2.xml new file mode 100644 index 0000000000..bb43e66dcb --- /dev/null +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Resources/VendorRobotics.NodeSet2.xml @@ -0,0 +1,59 @@ + + + + http://vendor.example.com/UA/Robotics/ + http://opcfoundation.org/UA/Robotics/ + + + + + + + + + i=12 + i=47 + i=46 + i=45 + i=40 + i=37 + + + + VendorMotionDeviceType + + ns=2;i=1004 + ns=1;i=7002 + ns=1;i=7010 + + + + VendorControllerName + + i=68 + i=78 + + + + + VendorAxisType + + ns=2;i=16601 + ns=1;i=7011 + i=78 + + + + VendorAxisLabel + + i=68 + i=78 + + + diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/Schema/NodeSetToModelDesignTests.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/Schema/NodeSetToModelDesignTests.cs index 12a724ab77..291c8b9a4a 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/Schema/NodeSetToModelDesignTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/Schema/NodeSetToModelDesignTests.cs @@ -32,8 +32,10 @@ using System.IO; using System.Linq; using System.Text; +using System.Xml; using Microsoft.Extensions.Logging; using NUnit.Framework; +using Opc.Ua.Export; using Opc.Ua.Tests; namespace Opc.Ua.Schema.Model.Tests @@ -203,6 +205,43 @@ public void ImportWithoutBaseModelThrowsInvalidDataException() Assert.That(ex.Message, Does.Contain("WidgetType")); } + [Test] + public void TypeSymbolicNameUsesNodeIdNamespace() + { + var symbolicId = new XmlQualifiedName( + "GroundControlPointDataType", + "http://opcfoundation.org/UA/GPOS/"); + var symbolicName = new XmlQualifiedName( + "GroundControlPointDataType", + "http://opcfoundation.org/UA/RSL/"); + + XmlQualifiedName normalized = NodeSetToModelDesign.NormalizeSymbolicNameNamespace( + new UADataType(), + symbolicId, + symbolicName); + + Assert.That(normalized.Name, Is.EqualTo(symbolicName.Name)); + Assert.That(normalized.Namespace, Is.EqualTo(symbolicId.Namespace)); + } + + [Test] + public void InstanceSymbolicNameKeepsBrowseNameNamespace() + { + var symbolicId = new XmlQualifiedName( + "Position", + "http://opcfoundation.org/UA/GPOS/"); + var symbolicName = new XmlQualifiedName( + "Position", + "http://opcfoundation.org/UA/RSL/"); + + XmlQualifiedName normalized = NodeSetToModelDesign.NormalizeSymbolicNameNamespace( + new UAVariable(), + symbolicId, + symbolicName); + + Assert.That(normalized, Is.SameAs(symbolicName)); + } + private static ITelemetryContext CreateTelemetry() { return NUnitTelemetryContext.Create(logLevel: LogLevel.Error); diff --git a/tests/Opc.Ua.SourceGeneration.Tests/ModelDependencyScannerTests.cs b/tests/Opc.Ua.SourceGeneration.Tests/ModelDependencyScannerTests.cs index 445cf94e5c..9fb3574086 100644 --- a/tests/Opc.Ua.SourceGeneration.Tests/ModelDependencyScannerTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Tests/ModelDependencyScannerTests.cs @@ -30,10 +30,14 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; +using System.IO; using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; using NUnit.Framework; +using Opc.Ua.SourceGeneration.Dependency; namespace Opc.Ua.SourceGeneration { @@ -179,6 +183,52 @@ public void EmittedAssemblyContainsModelDependencyAttribute() Assert.That(text, Does.Not.Contain("\"http://opcfoundation.org/UA/\"")); } + [Test] + public void DeclarationBackedMethodIdentityCompilesAcrossAssemblies() + { + CSharpCompilation producer = GenerateModelCompilation( + "DeclarationBackedMethodProducer", + "DeclarationBackedMethod.NodeSet2.xml", + "DeclarationBackedMethod"); + CSharpCompilation consumerBase = + CreateStackCompilation("DeclarationBackedMethodConsumer") + .AddReferences(producer.ToMetadataReference()); + + (GeneratorRunResult result, Compilation output, ImmutableArray diagnostics) = RunModelGenerator( + consumerBase, + "DeclarationBackedMethodConsumer.NodeSet2.xml", + "DeclarationBackedMethodConsumer"); + + ImmutableArray outputDiagnostics = output.GetDiagnostics(); + Assert.Multiple(() => + { + Assert.That( + diagnostics.Where(diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, diagnostics)); + Assert.That( + outputDiagnostics.Where(diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, outputDiagnostics)); + }); + + string generated = string.Join( + Environment.NewLine, + result.GeneratedSources.Select(source => source.SourceText.ToString())); + Assert.Multiple(() => + { + Assert.That( + generated, + Does.Contain( + "global::DeclarationBackedMethod.AdjustDeclarationMethodState")); + Assert.That( + generated, + Does.Not.Contain("global::DeclarationBackedMethod.AdjustMethodState")); + }); + } + [Test] public void OverrideResolutionSilentlySkipsLocalGeneration() { @@ -230,5 +280,833 @@ public void OverrideResolutionSilentlySkipsLocalGeneration() "a referenced assembly already declares the same model URI " + "under the same C# prefix"); } + + [Test] + public void FluentAccessorsOnlyEmitsAccessorsAgainstReferencedStateTypes() + { + CSharpCompilation producer = s_producerWithoutAccessors.Value; + (GeneratorRunResult generatorResult, Compilation outputCompilation, + ImmutableArray generatorDiagnostics) = + RunFluentAccessorsOnly(producer); + + Assert.That( + generatorDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "Fluent-accessors-only generation must not report errors."); + Assert.That(generatorResult.GeneratedSources, Has.Length.EqualTo(1)); + + GeneratedSourceResult source = generatorResult.GeneratedSources.Single(); + string text = source.SourceText.ToString(); + Assert.Multiple(() => + { + Assert.That(source.HintName, Does.EndWith(".FluentBuilders.g.cs")); + Assert.That(text, Does.Contain( + "public static partial class RestrictedObjectStateComponents")); + Assert.That(text, Does.Contain( + "global::DemoModel.RestrictedObjectState")); + Assert.That(text, Does.Not.Match( + @"internal\s+interface\s+I\w+NodeManagerBuilder")); + Assert.That(text, Does.Not.Match( + @"public\s+partial\s+class\s+RestrictedObjectState\b")); + Assert.That(generatorResult.GeneratedSources.Select(s => s.HintName), + Has.None.EndsWith(".Constants.g.cs")); + Assert.That(generatorResult.GeneratedSources.Select(s => s.HintName), + Has.None.EndsWith(".NodeStates.g.cs")); + Assert.That(generatorResult.GeneratedSources.Select(s => s.HintName), + Has.None.EndsWith(".ModelDependencies.g.cs")); + }); + + ImmutableArray outputDiagnostics = outputCompilation.GetDiagnostics(); + Assert.That( + outputDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, outputDiagnostics)); + } + + [Test] + public void FluentAccessorsOnlyRejectsProducerThatAlreadyEmittedAccessors() + { + (GeneratorRunResult result, _, ImmutableArray diagnostics) = + RunFluentAccessorsOnly(s_producerWithAccessors.Value); + + Diagnostic diagnostic = diagnostics.Single(d => d.Id == "MODELGEN014"); + string message = diagnostic.GetMessage(CultureInfo.InvariantCulture); + Assert.Multiple(() => + { + Assert.That(result.GeneratedSources, Is.Empty); + Assert.That(message, Does.Contain(DemoModelUri)); + Assert.That(message, Does.Contain("DemoModel")); + Assert.That(message, + Does.Contain("already provides generated fluent accessors")); + }); + } + + [Test] + public void FluentAccessorsOnlyProviderPreventsDownstreamSecondEmission() + { + (GeneratorRunResult firstResult, Compilation firstCompilation, + ImmutableArray firstDiagnostics) = RunFluentAccessorsOnly( + s_producerWithoutAccessors.Value, + assemblyName: "AccessorProvider"); + Assert.That( + firstDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty); + Assert.That(firstResult.GeneratedSources, Has.Length.EqualTo(1)); + + MetadataReference accessorProvider = firstCompilation.ToMetadataReference(); + CSharpCompilation probe = CreateStackCompilation("AccessorProviderProbe") + .AddReferences(accessorProvider); + ModelFluentAccessorProviderReference provider = + ReferencedFluentAccessorProviderScanner.Scan(probe) + .Single(reference => reference.ModelUri == DemoModelUri); + Assert.That(provider.Prefix, Is.EqualTo("DemoModel")); + + (GeneratorRunResult secondResult, _, ImmutableArray secondDiagnostics) = RunFluentAccessorsOnly( + s_producerWithoutAccessors.Value, + additionalReferences: [accessorProvider], + assemblyName: "DownstreamConsumer"); + + Diagnostic diagnostic = secondDiagnostics.Single(d => d.Id == "MODELGEN014"); + Assert.Multiple(() => + { + Assert.That(secondResult.GeneratedSources, Is.Empty); + Assert.That( + diagnostic.GetMessage(CultureInfo.InvariantCulture), + Does.Contain("already provides generated fluent accessors")); + }); + } + + [Test] + public void VendorRoboticsSubtypeExtensionCompilesAgainstReferencedModelAndAccessorProvider() + { + CSharpCompilation roboticsModel = CreateGeneratedRoboticsModelProducer(); + CSharpCompilation roboticsAccessorProvider = + CreateGeneratedRoboticsAccessorProvider(roboticsModel); + + CSharpCompilation vendorBase = CreateStackCompilation("VendorRobotics") + .AddReferences( + s_diProducer.Value.ToMetadataReference(), + roboticsModel.ToMetadataReference(), + roboticsAccessorProvider.ToMetadataReference()); + + (GeneratorRunResult result, Compilation outputCompilation, + ImmutableArray diagnostics) = RunVendorRoboticsGenerator(vendorBase); + + ImmutableArray outputDiagnostics = outputCompilation.GetDiagnostics(); + string generated = string.Join( + Environment.NewLine, + result.GeneratedSources.Select(source => source.SourceText.ToString())); + + Assert.Multiple(() => + { + Assert.That( + diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, diagnostics)); + Assert.That( + outputDiagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, outputDiagnostics)); + Assert.That( + generated, + Does.Match( + @"public\s+partial\s+class\s+VendorMotionDeviceState\s*:\s*" + + @"global::Opc\.Ua\.Robotics\.MotionDeviceState")); + Assert.That( + generated, + Does.Match( + @"public\s+partial\s+class\s+VendorAxisState\s*:\s*" + + @"global::Opc\.Ua\.Robotics\.AxisState")); + Assert.That(generated, Does.Contain("namespace Vendor.Robotics")); + Assert.That(generated, Does.Contain("public static partial class ObjectTypeIds")); + Assert.That(generated, Does.Contain("VendorMotionDeviceType")); + Assert.That(generated, Does.Contain("VendorAxisType")); + Assert.That( + generated, + Does.Match(@"public\s+static\s+partial\s+class\s+VendorMotionDeviceStateComponents\b")); + Assert.That( + generated, + Does.Match(@"public\s+static\s+partial\s+class\s+VendorAxisStateComponents\b")); + Assert.That(generated, Does.Not.Match(@"public\s+partial\s+class\s+MotionDeviceState\b")); + Assert.That(generated, Does.Not.Match(@"public\s+partial\s+class\s+AxisState\b")); + Assert.That( + generated, + Does.Not.Match(@"public\s+static\s+partial\s+class\s+MotionDeviceStateComponents\b")); + Assert.That( + generated, + Does.Not.Match(@"public\s+static\s+partial\s+class\s+AxisStateComponents\b")); + }); + } + + [Test] + public void PayloadlessTransitiveReexportDoesNotWinOrRejectSelfProducer() + { + CSharpCompilation reexport = CreateModelMetadataAssembly( + "TransitiveReexport", + prefix: "DemoModel.Transitive", + version: "999.0", + payload: null); + + (GeneratorRunResult normalResult, _, ImmutableArray normalDiagnostics) = RunFluentAccessorsOnly( + s_producerWithoutAccessors.Value, + additionalReferences: [reexport.ToMetadataReference()], + fluentAccessorsOnly: false, + assemblyName: "NormalConsumer"); + Assert.Multiple(() => + { + Assert.That( + normalDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty); + Assert.That(normalResult.GeneratedSources, Is.Empty, + "Normal duplicate suppression should select the payload-bearing producer."); + }); + + (GeneratorRunResult accessorResult, _, ImmutableArray accessorDiagnostics) = + RunFluentAccessorsOnly( + s_producerWithoutAccessors.Value, + additionalReferences: [reexport.ToMetadataReference()], + assemblyName: "AccessorConsumer"); + Assert.Multiple(() => + { + Assert.That( + accessorDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty); + Assert.That(accessorResult.GeneratedSources, Has.Length.EqualTo(1)); + }); + } + + [Test] + public void CorruptLowerVersionCandidateDoesNotAbortNormalGeneration() + { + string validPayload = new ModelDependencyV1 + { + ModelUri = DemoModelUri, + FluentAccessorsEmitted = false + }.ToBase64Payload(); + CSharpCompilation validProducer = CreateModelMetadataAssembly( + "ValidProducer", + prefix: "DemoModel", + version: "1.05.03", + payload: validPayload); + CSharpCompilation corruptCandidate = CreateModelMetadataAssembly( + "CorruptCandidate", + prefix: "DemoModel", + version: "0.0", + payload: Convert.ToBase64String([0xAA, 0xC7, 0x01, 0x01, 0xFF])); + + (GeneratorRunResult result, _, ImmutableArray diagnostics) = RunFluentAccessorsOnly( + validProducer, + additionalReferences: [corruptCandidate.ToMetadataReference()], + fluentAccessorsOnly: false, + assemblyName: "NormalConsumer"); + + Assert.Multiple(() => + { + Assert.That( + diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty); + Assert.That(result.GeneratedSources, Is.Empty, + "The valid payload-bearing producer should suppress duplicate generation."); + }); + } + + [Test] + public void FluentAccessorsOnlyRejectsCorruptSamePrefixProvider() + { + CSharpCompilation corruptProvider = CreateModelMetadataAssembly( + "CorruptProvider", + prefix: "DemoModel", + version: "0.0", + payload: "not!valid!base64!@@"); + + (GeneratorRunResult result, _, ImmutableArray diagnostics) = RunFluentAccessorsOnly( + s_producerWithoutAccessors.Value, + additionalReferences: [corruptProvider.ToMetadataReference()], + assemblyName: "ConservativeConsumer"); + + Diagnostic diagnostic = diagnostics.Single(d => d.Id == "MODELGEN014"); + Assert.Multiple(() => + { + Assert.That(result.GeneratedSources, Is.Empty); + Assert.That( + diagnostic.GetMessage(CultureInfo.InvariantCulture), + Does.Contain("malformed model dependency payload")); + }); + } + + [Test] + public void MultipleActualProducersRejectUnknownLegacyCapability() + { + string legacyPayload = new ModelDependencyV1 + { + ModelUri = DemoModelUri + }.ToBase64Payload(); + CSharpCompilation legacyProducer = CreateModelMetadataAssembly( + "LegacyProducer", + prefix: "DemoModel", + version: "999.0", + payload: legacyPayload); + + (GeneratorRunResult result, _, ImmutableArray diagnostics) = RunFluentAccessorsOnly( + s_producerWithoutAccessors.Value, + additionalReferences: [legacyProducer.ToMetadataReference()], + assemblyName: "ConservativeConsumer"); + + Diagnostic diagnostic = diagnostics.Single(d => d.Id == "MODELGEN014"); + Assert.Multiple(() => + { + Assert.That(result.GeneratedSources, Is.Empty); + Assert.That( + diagnostic.GetMessage(CultureInfo.InvariantCulture), + Does.Contain("unknown legacy fluent-accessor capability")); + }); + } + + [Test] + public void FluentAccessorsOnlyRejectsMissingReferencedModel() + { + (GeneratorRunResult result, _, ImmutableArray diagnostics) = + RunFluentAccessorsOnly(producer: null, includeDiNodeSet: true); + + Diagnostic[] modelDiagnostics = + [.. diagnostics.Where(d => d.Id == "MODELGEN014")]; + Diagnostic diagnostic = modelDiagnostics + .First(d => d.Id == "MODELGEN014" && + d.GetMessage(CultureInfo.InvariantCulture).Contains( + DemoModelUri, + StringComparison.Ordinal)); + string message = diagnostic.GetMessage(CultureInfo.InvariantCulture); + Assert.Multiple(() => + { + Assert.That(result.GeneratedSources, Is.Empty); + Assert.That(modelDiagnostics, Has.Length.EqualTo(2), + "Both invalid local models should be diagnosed."); + Assert.That(message, + Does.Contain("no payload-bearing referenced model producer")); + Assert.That(message, Does.Contain("DemoModel.NodeSet2.xml")); + }); + } + + [Test] + public void FluentAccessorsOnlyRejectsMismatchedReferencedPrefix() + { + const string consumerPrefix = "DemoModel.Consumer"; + (GeneratorRunResult result, _, ImmutableArray diagnostics) = RunFluentAccessorsOnly( + s_producerWithoutAccessors.Value, + consumerPrefix: consumerPrefix); + + Diagnostic diagnostic = diagnostics.Single(d => d.Id == "MODELGEN014"); + string message = diagnostic.GetMessage(CultureInfo.InvariantCulture); + Assert.Multiple(() => + { + Assert.That(result.GeneratedSources, Is.Empty); + Assert.That(message, Does.Contain(consumerPrefix)); + Assert.That(message, + Does.Contain("supplies prefix 'DemoModel'")); + }); + } + + [TestCase("ModelSourceGeneratorOmitFluentApi")] + [TestCase("ModelSourceGeneratorGenerateNodeManager")] + public void FluentAccessorsOnlyRejectsInvalidOptionCombinations(string incompatibleOption) + { + var optionOverrides = new Dictionary + { + ["build_property." + incompatibleOption] = "true" + }; + + (GeneratorRunResult result, _, ImmutableArray diagnostics) = RunFluentAccessorsOnly( + s_producerWithoutAccessors.Value, + optionOverrides: optionOverrides); + + Diagnostic diagnostic = diagnostics.Single(d => d.Id == "MODELGEN015"); + string message = diagnostic.GetMessage(CultureInfo.InvariantCulture); + Assert.Multiple(() => + { + Assert.That(result.GeneratedSources, Is.Empty); + Assert.That(message, Does.Contain(incompatibleOption)); + }); + } + + private static (GeneratorRunResult Result, Compilation OutputCompilation, + ImmutableArray Diagnostics) RunFluentAccessorsOnly( + CSharpCompilation producer, + string consumerPrefix = null, + bool includeDiNodeSet = false, + IReadOnlyDictionary optionOverrides = null, + IReadOnlyList additionalReferences = null, + bool fluentAccessorsOnly = true, + string assemblyName = "Consumer") + { + CSharpCompilation compilation = CreateStackCompilation(assemblyName); + if (producer != null) + { + compilation = compilation.AddReferences( + s_diProducer.Value.ToMetadataReference(), + producer.ToMetadataReference()); + } + if (additionalReferences != null) + { + compilation = compilation.AddReferences(additionalReferences); + } + + var globalOptions = new Dictionary + { + ["build_property.ModelSourceGeneratorVersion"] = "v105", + ["build_property.ModelSourceGeneratorExclude"] = "Draft" + }; + if (fluentAccessorsOnly) + { + globalOptions["build_property.ModelSourceGeneratorFluentAccessorsOnly"] = "true"; + } + if (optionOverrides != null) + { + foreach (KeyValuePair option in optionOverrides) + { + globalOptions[option.Key] = option.Value; + } + } + var options = new AnalyzerOptionsProvider(globalOptions); + if (!string.IsNullOrEmpty(consumerPrefix)) + { + options.TextOptions["DemoModel.NodeSet2.xml"] = new Dictionary + { + ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = consumerPrefix + }; + } + + var additionalTexts = new List + { + EmbeddedText.From("DemoModel.NodeSet2.xml") + }; + if (includeDiNodeSet) + { + additionalTexts.Add(EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml")); + } + + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(LanguageVersion.CSharp11)) + .AddAdditionalTexts([.. additionalTexts]) + .WithUpdatedAnalyzerConfigOptions(options); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics); + + return (driver.GetRunResult().Results[0], outputCompilation, diagnostics); + } + + private static (GeneratorRunResult Result, Compilation OutputCompilation, + ImmutableArray Diagnostics) RunVendorRoboticsGenerator( + CSharpCompilation compilation) + { + var options = new AnalyzerOptionsProvider( + new Dictionary + { + ["build_property.ModelSourceGeneratorVersion"] = "v105", + ["build_property.ModelSourceGeneratorStartId"] = "9000", + ["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true" + }); + options.TextOptions["VendorRobotics.NodeSet2.xml"] = new Dictionary + { + ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = "Vendor.Robotics" + }; + + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(LanguageVersion.CSharp11)) + .AddAdditionalTexts([EmbeddedText.From("VendorRobotics.NodeSet2.xml")]) + .WithUpdatedAnalyzerConfigOptions(options); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics); + + return (driver.GetRunResult().Results[0], outputCompilation, diagnostics); + } + + private static CSharpCompilation GenerateModelCompilation( + string assemblyName, + string nodeSetResource, + string prefix) + { + CSharpCompilation compilation = CreateStackCompilation(assemblyName); + (GeneratorRunResult _, Compilation output, ImmutableArray diagnostics) = + RunModelGenerator(compilation, nodeSetResource, prefix); + ImmutableArray outputDiagnostics = output.GetDiagnostics(); + Assert.Multiple(() => + { + Assert.That( + diagnostics.Where(diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, diagnostics)); + Assert.That( + outputDiagnostics.Where(diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, outputDiagnostics)); + }); + return (CSharpCompilation)output; + } + + private static (GeneratorRunResult Result, Compilation OutputCompilation, + ImmutableArray Diagnostics) RunModelGenerator( + CSharpCompilation compilation, + string nodeSetResource, + string prefix) + { + var options = new AnalyzerOptionsProvider( + new Dictionary + { + ["build_property.ModelSourceGeneratorStartId"] = "5000", + ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true" + }); + options.TextOptions[nodeSetResource] = new Dictionary + { + ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = prefix + }; + + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(LanguageVersion.CSharp11)) + .AddAdditionalTexts([EmbeddedText.From(nodeSetResource)]) + .WithUpdatedAnalyzerConfigOptions(options); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics); + return (driver.GetRunResult().Results[0], outputCompilation, diagnostics); + } + + private static CSharpCompilation CreateGeneratedRoboticsModelProducer() + { + CSharpCompilation compilation = CreateStackCompilation("RoboticsProducer") + .AddReferences(s_diProducer.Value.ToMetadataReference()); + compilation = compilation.WithOptions( + compilation.Options.WithSpecificDiagnosticOptions( + new Dictionary + { + ["CS0108"] = ReportDiagnostic.Suppress + })); + var options = new AnalyzerOptionsProvider( + new Dictionary + { + ["build_property.ModelSourceGeneratorVersion"] = "v105", + ["build_property.ModelSourceGeneratorStartId"] = "2000", + ["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true", + ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true" + }); + options.TextOptions["Opc.Ua.IA.NodeSet2.xml"] = new Dictionary + { + ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = "Opc.Ua.IA" + }; + options.TextOptions["Opc.Ua.Robotics.NodeSet2.xml"] = new Dictionary + { + ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = "Opc.Ua.Robotics" + }; + + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(LanguageVersion.CSharp11)) + .AddAdditionalTexts( + [ + CreateRoboticsModelText("Opc.Ua.IA.NodeSet2.xml"), + CreateRoboticsModelText("Opc.Ua.Robotics.NodeSet2.xml") + ]) + .WithUpdatedAnalyzerConfigOptions(options); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics); + + Assert.That( + diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, diagnostics)); + Assert.That( + outputCompilation.GetDiagnostics().Where(diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, outputCompilation.GetDiagnostics())); + return (CSharpCompilation)outputCompilation; + } + + private static CSharpCompilation CreateGeneratedRoboticsAccessorProvider( + CSharpCompilation roboticsModel) + { + CSharpCompilation compilation = CreateStackCompilation("RoboticsAccessorProvider") + .AddReferences( + s_diProducer.Value.ToMetadataReference(), + roboticsModel.ToMetadataReference()); + var options = new AnalyzerOptionsProvider( + new Dictionary + { + ["build_property.ModelSourceGeneratorVersion"] = "v105", + ["build_property.ModelSourceGeneratorUseAllowSubtypes"] = "true", + ["build_property.ModelSourceGeneratorFluentAccessorsOnly"] = "true" + }); + options.TextOptions["Opc.Ua.Robotics.NodeSet2.xml"] = new Dictionary + { + ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = "Opc.Ua.Robotics" + }; + + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(LanguageVersion.CSharp11)) + .AddAdditionalTexts([CreateRoboticsModelText("Opc.Ua.Robotics.NodeSet2.xml")]) + .WithUpdatedAnalyzerConfigOptions(options); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics); + + Assert.That( + diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, diagnostics)); + Assert.That( + outputCompilation.GetDiagnostics().Where(diagnostic => + diagnostic.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, outputCompilation.GetDiagnostics())); + return (CSharpCompilation)outputCompilation; + } + + private static StringAdditionalText CreateRoboticsModelText(string fileName) + { + string repositoryRoot = FindRepositoryRoot(); + string path = Path.Combine( + repositoryRoot, + "src", + "Opc.Ua.Robotics", + "Model", + fileName); + return new StringAdditionalText(fileName, File.ReadAllText(path)); + } + + private static string FindRepositoryRoot() + { + string assemblyDirectory = + Path.GetDirectoryName(typeof(ModelDependencyScannerTests).Assembly.Location) + ?? throw new InvalidOperationException("Test assembly directory was not found."); + var directory = new DirectoryInfo(assemblyDirectory); + for (int i = 0; i < 5; i++) + { + directory = directory.Parent + ?? throw new InvalidOperationException("Repository root was not found."); + } + return directory.FullName; + } + + private static CSharpCompilation CreateGeneratedDemoModelProducer(bool omitFluentApi) + { + CSharpCompilation compilation = CreateStackCompilation( + omitFluentApi ? "ProducerWithoutAccessors" : "ProducerWithAccessors") + .AddReferences(s_diProducer.Value.ToMetadataReference()); + var options = new AnalyzerOptionsProvider( + new Dictionary + { + ["build_property.ModelSourceGeneratorStartId"] = "1000", + ["build_property.ModelSourceGeneratorOmitFluentApi"] = + omitFluentApi ? "true" : "false" + }); + + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(LanguageVersion.CSharp11)) + .AddAdditionalTexts([EmbeddedText.From("DemoModel.NodeSet2.xml")]) + .WithUpdatedAnalyzerConfigOptions(options); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics); + + GeneratorRunResult result = driver.GetRunResult().Results[0]; + Assert.That( + diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "Generated producer must not report errors."); + Assert.That( + outputCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, outputCompilation.GetDiagnostics())); + Assert.That( + result.GeneratedSources.Any(s => s.HintName.EndsWith( + ".FluentBuilders.g.cs", + StringComparison.Ordinal)), + Is.EqualTo(!omitFluentApi)); + + CSharpCompilation probe = CreateStackCompilation("ProducerProbe") + .AddReferences( + s_diProducer.Value.ToMetadataReference(), + outputCompilation.ToMetadataReference()); + ModelDependencyReference dependency = ReferencedModelDependencyScanner.Scan(probe) + .Single(r => r.ModelUri == DemoModelUri); + Assert.That( + dependency.GetDependency()?.FluentAccessorsEmitted, + Is.EqualTo(!omitFluentApi)); + ModelFluentAccessorProviderReference[] providers = + [.. ReferencedFluentAccessorProviderScanner.Scan(probe) + .Where(reference => reference.ModelUri == DemoModelUri)]; + Assert.That(providers, Has.Length.EqualTo(omitFluentApi ? 0 : 1)); + + return (CSharpCompilation)outputCompilation; + } + + private static CSharpCompilation CreateModelMetadataAssembly( + string assemblyName, + string prefix, + string version, + string payload) + { + string payloadLiteral = payload == null ? "null" : "\"" + payload + "\""; + string source = + "[assembly: global::Opc.Ua.ModelDependencyAttribute(" + + "\"" + DemoModelUri + "\", " + + "\"" + prefix + "\", " + + "\"" + version + "\", " + + "\"2099-01-01\", " + + "\"DemoModel\", " + + payloadLiteral + ")]"; + CSharpCompilation compilation = CreateStackCompilation(assemblyName) + .AddCode(new Dictionary + { + ["ModelMetadata.cs"] = source + }, LanguageVersion.CSharp11); + Assert.That( + compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, compilation.GetDiagnostics())); + return compilation; + } + + private static CSharpCompilation CreateGeneratedDiProducer() + { + CSharpCompilation compilation = CreateStackCompilation("DiProducer"); + var options = new AnalyzerOptionsProvider( + new Dictionary + { + ["build_property.ModelSourceGeneratorStartId"] = "1000", + ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true" + }); + + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(LanguageVersion.CSharp11)) + .AddAdditionalTexts([EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml")]) + .WithUpdatedAnalyzerConfigOptions(options); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics); + + Assert.That( + diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "Generated DI producer must not report errors."); + Assert.That( + outputCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + string.Join(Environment.NewLine, outputCompilation.GetDiagnostics())); + return (CSharpCompilation)outputCompilation; + } + + private static CSharpCompilation CreateStackCompilation(string assemblyName) + { + CSharpCompilation compilation = + OptimizationLevel.Release.CreateCompilation(assemblyName); + return compilation + .WithOptions(compilation.Options.WithGeneralDiagnosticOption( + ReportDiagnostic.Error)) + .AddReferences(GetStackReferences()); + } + + private static IEnumerable GetStackReferences() + { + string assemblyDirectory = + Path.GetDirectoryName(typeof(ModelDependencyScannerTests).Assembly.Location) + ?? throw new InvalidOperationException("Test assembly directory was not found."); + var directory = new DirectoryInfo(assemblyDirectory); + string targetFramework = directory.Name; + string configuration = directory.Parent?.Name + ?? throw new InvalidOperationException("Test configuration directory was not found."); + for (int i = 0; i < 5; i++) + { + directory = directory.Parent + ?? throw new InvalidOperationException("Repository root was not found."); + } + string serverOutput = Path.Combine( + directory.FullName, + "src", + "Opc.Ua.Server", + "bin", + configuration, + targetFramework); + return Directory.EnumerateFiles(serverOutput, "Opc.Ua*.dll") + .Where(path => !string.Equals( + Path.GetFileName(path), + "Opc.Ua.Types.dll", + StringComparison.OrdinalIgnoreCase)) + .Select(path => MetadataReference.CreateFromFile(path)); + } + + private const string DemoModelUri = "urn:opcfoundation.org:2024-01:DemoModel"; + + private static readonly Lazy s_diProducer = + new(CreateGeneratedDiProducer); + + private static readonly Lazy s_producerWithoutAccessors = + new(() => CreateGeneratedDemoModelProducer(omitFluentApi: true)); + + private static readonly Lazy s_producerWithAccessors = + new(() => CreateGeneratedDemoModelProducer(omitFluentApi: false)); + + private sealed class StringAdditionalText : AdditionalText + { + public StringAdditionalText(string path, string text) + { + Path = path; + m_text = SourceText.From(text); + } + + public override string Path { get; } + + public override SourceText GetText(System.Threading.CancellationToken cancellationToken = default) + { + return m_text; + } + + private readonly SourceText m_text; + } } } diff --git a/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj b/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj index bf06ba8e52..a6a684c90f 100644 --- a/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj +++ b/tests/Opc.Ua.SourceGeneration.Tests/Opc.Ua.SourceGeneration.Tests.csproj @@ -32,6 +32,8 @@ + diff --git a/tests/Opc.Ua.Types.Tests/Encoders/JsonDecoderCustomMessageTests.cs b/tests/Opc.Ua.Types.Tests/Encoders/JsonDecoderCustomMessageTests.cs new file mode 100644 index 0000000000..df038ffd4a --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Encoders/JsonDecoderCustomMessageTests.cs @@ -0,0 +1,143 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Runtime.Serialization; +using NUnit.Framework; + +namespace Opc.Ua.Types.Tests.Encoders +{ + [TestFixture] + [Category("Json")] + public sealed class JsonDecoderCustomMessageTests + { + [Test] + public void CustomMessageNormalizesTypeIdAndInfersEncodingMask() + { + var context = ServiceMessageContext.Create(null); + context.NamespaceUris.GetIndexOrAppend(OptionalEncodeable.NamespaceUri); + context.Factory.Builder + .AddType(OptionalEncodeable.DataTypeId, typeof(OptionalEncodeable)) + .AddType(OptionalEncodeable.BinaryId, typeof(OptionalEncodeable)) + .AddType(OptionalEncodeable.XmlId, typeof(OptionalEncodeable)) + .Commit(); + var expected = new OptionalEncodeable + { + EncodingMask = 1, + Required = 42, + Optional = 84 + }; + + string json; + using (var encoder = new JsonEncoder(context)) + { + encoder.EncodeMessage(expected, expected.TypeId); + json = encoder.CloseAndReturnText(); + } + using var decoder = new JsonDecoder(json, context); + OptionalEncodeable actual = + decoder.DecodeMessage(); + + Assert.Multiple(() => + { + Assert.That(actual.EncodingMask, Is.EqualTo(1)); + Assert.That(actual.Required, Is.EqualTo(42)); + Assert.That(actual.Optional, Is.EqualTo(84)); + }); + } + + [DataContract(Namespace = NamespaceUri)] + public sealed class OptionalEncodeable : IEncodeable + { + public const string NamespaceUri = "urn:opcfoundation:json:test"; + + public static readonly ExpandedNodeId DataTypeId = + new(9001, NamespaceUri); + + public static readonly ExpandedNodeId BinaryId = + new(9002, NamespaceUri); + + public static readonly ExpandedNodeId XmlId = + new(9003, NamespaceUri); + + public uint EncodingMask { get; set; } + + public int Required { get; set; } + + public int Optional { get; set; } + + public ExpandedNodeId TypeId => DataTypeId; + + public ExpandedNodeId BinaryEncodingId => BinaryId; + + public ExpandedNodeId XmlEncodingId => XmlId; + + public void Encode(IEncoder encoder) + { + encoder.PushNamespace(NamespaceUri); + encoder.WriteEncodingMask(EncodingMask); + encoder.WriteInt32(nameof(Required), Required); + if ((EncodingMask & 1) != 0) + { + encoder.WriteInt32(nameof(Optional), Optional); + } + encoder.PopNamespace(); + } + + public void Decode(IDecoder decoder) + { + decoder.PushNamespace(NamespaceUri); + EncodingMask = decoder.ReadEncodingMask([nameof(Optional)]); + Required = decoder.ReadInt32(nameof(Required)); + if ((EncodingMask & 1) != 0) + { + Optional = decoder.ReadInt32(nameof(Optional)); + } + decoder.PopNamespace(); + } + + public bool IsEqual(IEncodeable encodeable) + { + return encodeable is OptionalEncodeable other && + EncodingMask == other.EncodingMask && + Required == other.Required && + Optional == other.Optional; + } + + public object Clone() + { + return new OptionalEncodeable + { + EncodingMask = EncodingMask, + Required = Required, + Optional = Optional + }; + } + } + } +} diff --git a/tests/Opc.Ua.Types.Tests/State/NodeStateTests.cs b/tests/Opc.Ua.Types.Tests/State/NodeStateTests.cs index ef966333b8..c6cdbb39ca 100644 --- a/tests/Opc.Ua.Types.Tests/State/NodeStateTests.cs +++ b/tests/Opc.Ua.Types.Tests/State/NodeStateTests.cs @@ -2500,6 +2500,36 @@ public void OnPopulateBrowserHandlerInvoked() Assert.That(invoked, Is.True); } + [Test] + public void CopyingANodeDoesNotConsumeNodeIdsFromTheFactory() + { + var source = new MethodState(null) + { + NodeId = new NodeId(4000, 0), + BrowseName = QualifiedName.From("Move"), + DisplayName = LocalizedText.From("Move") + }; + PropertyState> sourceArguments = + source.CreateOrReplaceInputArguments(m_context, null); + sourceArguments.NodeId = new NodeId(4001, 0); + + var factory = new CountingNodeIdFactory(); + var context = new SystemContext(m_telemetry) + { + NamespaceUris = m_context.NamespaceUris, + ServerUris = m_context.ServerUris, + NodeIdFactory = factory + }; + + var copy = new MethodState(null); + copy.Create(context, source); + + Assert.That(factory.Count, Is.Zero); + Assert.That(copy.NodeId, Is.EqualTo(source.NodeId)); + Assert.That(copy.InputArguments, Is.Not.Null); + Assert.That(copy.InputArguments.NodeId, Is.EqualTo(sourceArguments.NodeId)); + } + [Test] public void AssignNodeIdsWithNoFactoryIsNoOp() { @@ -2591,5 +2621,20 @@ public void FindChildBrowsePathNavigatesMultipleLevels() Assert.That(found, Is.Not.Null); Assert.That(found.BrowseName, Is.EqualTo(QualifiedName.From("Leaf"))); } + + /// + /// Counts how often a NodeId was requested and hands out a fresh one + /// every time, so a test can prove a code path never reached it. + /// + private sealed class CountingNodeIdFactory : INodeIdFactory + { + public int Count { get; private set; } + + public NodeId New(ISystemContext context, NodeState node) + { + Count++; + return new NodeId((uint)(90000 + Count), 0); + } + } } } diff --git a/tools/Opc.Ua.OpenUsd.Connector.Viewer/NugetREADME.md b/tools/Opc.Ua.OpenUsd.Connector.Viewer/NugetREADME.md new file mode 100644 index 0000000000..7c9916eac4 --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector.Viewer/NugetREADME.md @@ -0,0 +1,38 @@ +# OPC UA โ€” OpenUSD connector viewport + +Optional viewport for [`OPCFoundation.NetStandard.Opc.Ua.OpenUsd.Connector`](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.OpenUsd.Connector/). + +The connector normally authors a `live.usda` override layer that some other tool renders. Install this package +alongside it and the connector's `--view` option instead opens a window on the composed stage and streams the same +subscribed OPC UA values straight into the stage being rendered, so the digital twin animates live in one process. + +``` +dotnet run --project tools/Opc.Ua.OpenUsd.Connector -- \ + --server opc.tcp://localhost:62830/MinimalRobotServer \ + --insecure --view +``` + +## How it fits together + +- The connector discovers the server's `Server/OpenUSD/Representations`, subscribes, and fetches the served USD + asset closure. +- This package implements `IUsdViewHost`: it renders the composed stage with the OpenUSD Avalonia viewer and hands + the connector an `IUsdSink` that authors into the scheduler-owned stage the renderer already owns. +- `CompositeUsdSink` fans every value into both that sink and the file sink, so the override layer on disk and the + picture on screen never diverge. + +The connector loads this assembly by name from its own directory and never references it, which is what keeps the +connector package free of a UI framework and a native payload. Publish both projects into the same directory so the +assembly, its dependencies, and the USD plugin tree end up where the connector looks for them. + +## Requirements + +- .NET 10 on `win-x64`. +- The OpenUSD packages `OpenUsd`, `OpenUsd.Viewer`, and `OpenUsd.Runtime.Imaging.win-x64`. + +See [`docs/OpenUsd.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/OpenUsd.md) for the full +binding guide and the local-feed bootstrap. + +## License + +MIT โ€” see the OPC Foundation MIT license. diff --git a/tools/Opc.Ua.OpenUsd.Connector.Viewer/Opc.Ua.OpenUsd.Connector.Viewer.csproj b/tools/Opc.Ua.OpenUsd.Connector.Viewer/Opc.Ua.OpenUsd.Connector.Viewer.csproj new file mode 100644 index 0000000000..8ef53726c2 --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector.Viewer/Opc.Ua.OpenUsd.Connector.Viewer.csproj @@ -0,0 +1,42 @@ + + + + net10.0 + Library + Opc.Ua.OpenUsd.Connector.Viewer + $(PackagePrefix).Opc.Ua.OpenUsd.Connector.Viewer + NugetREADME.md + MIT + Optional viewport for the OPC UA - OpenUSD connector. Renders the composed stage with the OpenUSD Avalonia viewer and exposes it as an IUsdSink, so the connector's --view option streams live OPC UA values into the picture on screen. Install alongside Opc.Ua.OpenUsd.Connector; the connector loads it on demand and works without it. + Opc.Ua.OpenUsd.Connector.Viewer + enable + true + true + + false + true + + + $(PackageId).Debug + + + + + + + + + + + + + + + diff --git a/tools/Opc.Ua.OpenUsd.Connector.Viewer/OpenUsdViewHost.cs b/tools/Opc.Ua.OpenUsd.Connector.Viewer/OpenUsdViewHost.cs new file mode 100644 index 0000000000..259277c5e5 --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector.Viewer/OpenUsdViewHost.cs @@ -0,0 +1,181 @@ +/* ======================================================================== + * 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.IO; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.OpenUsd.Client; +using OpenUsd.Viewer; + +namespace Opc.Ua.OpenUsd.Connector.Viewer +{ + /// + /// Renders the connector's composed stage with the OpenUSD Avalonia viewer and hands + /// the connector a sink that authors into that very stage, so subscribed OPC UA values + /// animate the picture on screen instead of a file nobody reloads. + /// + /// + /// The connector discovers this type by name through + /// and never references the rendering stack, so the + /// connector package stays free of Avalonia and the native OpenUSD payload. + /// + public sealed class OpenUsdViewHost : IUsdViewHost + { + /// + public void RunViewport( + UsdViewOptions options, + Func sessionAsync, + CancellationToken cancellationToken) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + if (sessionAsync is null) + { + throw new ArgumentNullException(nameof(sessionAsync)); + } + if (string.IsNullOrWhiteSpace(options.StagePath)) + { + throw new ArgumentException( + "A stage path is required to open the viewport.", nameof(options)); + } + + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + UsdStageSink? sink = null; + Task? session = null; + + var hostOptions = new ViewerHostOptions + { + StagePath = Path.GetFullPath(options.StagePath), + PluginPath = options.PluginPath ?? ResolvePluginPath(), + Renderer = options.Renderer, + Title = options.Title ?? "OPC UA - OpenUSD Connector", + StageCameraPath = options.CameraPath, + ShutdownToken = lifetime.Token, + StageReadyAsync = (stageSession, stageToken) => + { + sink = new UsdStageSink(stageSession.Scheduler, ReportSinkFailure); + // The viewport owns the UI thread, so the OPC UA pipeline runs on the + // thread pool. The stage token fires when the document closes, which + // is what stops the session. + session = Task.Run( + () => RunSessionAsync(sessionAsync, sink, lifetime.Token, stageToken), + CancellationToken.None); + return Task.CompletedTask; + } + }; + + try + { + ViewerEntryPoint.Run(hostOptions); + } + finally + { + lifetime.Cancel(); + DrainSession(session); + DisposeSink(sink); + } + } + + private static async Task RunSessionAsync( + Func sessionAsync, + IUsdSink sink, + CancellationToken hostToken, + CancellationToken stageToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + hostToken, stageToken); + try + { + await sessionAsync(sink, linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The window closed or the caller cancelled; both are ordinary shutdowns. + } +#pragma warning disable CA1031 // A failing session must not tear the viewport down. + catch (Exception exception) +#pragma warning restore CA1031 + { + Console.Error.WriteLine($"The OPC UA session ended with an error: {exception.Message}"); + } + } + + /// + /// Waits briefly for the session to unwind so its final values reach the stage, + /// without letting a stuck session block process exit. + /// + private static void DrainSession(Task? session) + { + if (session is null) + { + return; + } + try + { + session.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + // Already reported by RunSessionAsync. + } + } + + private static void DisposeSink(UsdStageSink? sink) + { + if (sink is null) + { + return; + } + try + { + sink.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + // The stage is going away regardless. + } + } + + private static void ReportSinkFailure(Exception exception) => + Console.Error.WriteLine($"A stage update failed: {exception.Message}"); + + /// + /// Locates the staged USD plugin tree next to the running application, which is + /// how both the viewer staging script and the runtime packages lay it out. + /// + private static string? ResolvePluginPath() + { + string candidate = Path.Combine(AppContext.BaseDirectory, "plugin", "usd"); + return Directory.Exists(candidate) ? candidate : null; + } + } +} diff --git a/tools/Opc.Ua.OpenUsd.Connector.Viewer/Properties/AssemblyInfo.cs b/tools/Opc.Ua.OpenUsd.Connector.Viewer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..2b9848014c --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector.Viewer/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * 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; + +[assembly: CLSCompliant(false)] diff --git a/tools/Opc.Ua.OpenUsd.Connector.Viewer/UsdStageSink.cs b/tools/Opc.Ua.OpenUsd.Connector.Viewer/UsdStageSink.cs new file mode 100644 index 0000000000..238e6f4b6d --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector.Viewer/UsdStageSink.cs @@ -0,0 +1,959 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.OpenUsd.Client; +using OpenUsd; +using OpenUsd.Geom; + +namespace Opc.Ua.OpenUsd.Connector.Viewer +{ + /// + /// Authors connector values straight into a scheduler-owned USD stage, so a running + /// viewport shows live OPC UA data instead of a file that has to be reloaded. + /// + /// + /// + /// is synchronous while the stage scheduler is asynchronous, so + /// calls never touch the stage directly. A single background pump ticks at a fixed + /// display rate and applies everything pending in one EditAsync. + /// + /// + /// Numeric values are interpolated rather than stepped. A subscription + /// delivers a new joint angle a few tens of times a second while the viewport draws + /// far more often, so stepping makes the twin visibly stutter. Each numeric target + /// instead moves from its previous value towards the newest one across roughly the + /// interval at which samples are arriving, which decouples how smooth the twin looks + /// from how fast the server publishes, at the cost of about one sample of lag. + /// Interpolation is linear because the server already eases between its key poses; + /// easing again here would distort the motion. + /// + /// + /// Tokens, colours, composition and history samples are not interpolated - there is no + /// meaningful value between "visible" and "invisible" - so they are applied on the + /// next tick and then left alone. + /// + /// + /// Structured double3 transform ops cannot be authored directly, because the + /// managed data API has no double3 setter. They are instead accumulated per + /// prim and written as a composed local transform. Components the server does not + /// bind default to no rotation and unit scale, and translation is seeded from the + /// prim's existing local transform so a rotation-only binding does not move the prim + /// to the origin. + /// + /// + internal sealed class UsdStageSink : IUsdSink, IAsyncDisposable + { + private const string TranslateOp = "xformOp:translate"; + private const string RotateOp = "xformOp:rotateXYZ"; + private const string ScaleOp = "xformOp:scale"; + + /// + /// Pump period, about 60 Hz. + /// + private static readonly TimeSpan s_tick = TimeSpan.FromMilliseconds(16); + + /// + /// Shortest interpolation window, so a burst cannot cause a visible jump. + /// + private static readonly long s_minWindow = Stopwatch.Frequency / 50; + + /// + /// Longest interpolation window, so a stalled feed settles instead of crawling. + /// + private static readonly long s_maxWindow = Stopwatch.Frequency / 2; + + private static readonly DateTime s_epoch = + new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Targets already reported as unwritable, so a persistent type mismatch + /// is logged once instead of on every frame. + /// + private static readonly ConcurrentDictionary s_rejectedTargets = + new(StringComparer.Ordinal); + + private readonly UsdStageScheduler m_scheduler; + private readonly Action m_onError; + private readonly Lock m_gate = new(); + private readonly CancellationTokenSource m_lifetime = new(); + private readonly Task m_pump; + + private readonly Dictionary m_prims = + new(StringComparer.Ordinal); + + private readonly Dictionary m_discrete = []; + private readonly Dictionary m_timeSamples = []; + private readonly Dictionary m_scalars = []; + + private readonly Dictionary m_transforms = + new(StringComparer.Ordinal); + + private int m_batchDepth; + + /// + /// Creates a sink over . + /// + /// The scheduler that owns the rendered stage. + /// Invoked when applying an update fails. + /// An argument is null. + public UsdStageSink(UsdStageScheduler scheduler, Action onError) + { + m_scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler)); + m_onError = onError ?? throw new ArgumentNullException(nameof(onError)); + m_pump = PumpAsync(m_lifetime.Token); + } + + /// + public void SetAttribute(string primPath, string propertyName, Variant value) + { + if (!UsdNames.IsValidPrimPath(primPath) || + !UsdNames.IsValidPropertyName(propertyName)) + { + return; + } + long now = Stopwatch.GetTimestamp(); + lock (m_gate) + { + if (TryUpdateTransform(primPath, propertyName, value, now) || + TryUpdateScalar(primPath, propertyName, value, now)) + { + return; + } + m_discrete[new AttributeKey(primPath, propertyName)] = value; + } + } + + /// + public void SetTimeSample(string primPath, string propertyName, DateTime time, Variant value) + { + if (!UsdNames.IsValidPrimPath(primPath) || + !UsdNames.IsValidPropertyName(propertyName)) + { + return; + } + double frame = (time.ToUniversalTime() - s_epoch).TotalSeconds; + lock (m_gate) + { + m_timeSamples[new TimeSampleKey(primPath, propertyName, frame)] = value; + } + } + + /// + public void ComposePrim(string primPath, OpenUsdCompositionArc arc, + string? assetReference, bool active) + { + if (!UsdNames.IsValidPrimPath(primPath)) + { + return; + } + lock (m_gate) + { + m_prims[primPath] = new PrimComposition(arc, assetReference, active); + } + } + + /// + public IDisposable BeginBatch() + { + lock (m_gate) + { + m_batchDepth++; + } + return new BatchScope(this); + } + + /// + /// Stops the pump and applies whatever is still pending. + /// + public async ValueTask DisposeAsync() + { + await m_lifetime.CancelAsync().ConfigureAwait(false); + try + { + await m_pump.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + m_lifetime.Dispose(); + } + + private void EndBatch() + { + lock (m_gate) + { + if (m_batchDepth > 0) + { + m_batchDepth--; + } + } + } + + /// + /// Applies pending work at a fixed display rate until cancelled. One iteration is + /// at most one stage edit, so neither a fast subscription nor the interpolation + /// can queue unbounded scheduler work. + /// + private async Task PumpAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(s_tick); + try + { + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + await ApplyPendingAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Shutting down. + } + // Let values that arrived during shutdown land, so a short run still leaves + // the stage in its final state. + await ApplyPendingAsync(CancellationToken.None).ConfigureAwait(false); + } + + private async Task ApplyPendingAsync(CancellationToken cancellationToken) + { + Snapshot snapshot; + lock (m_gate) + { + if (m_batchDepth > 0) + { + return; + } + snapshot = Snapshot.Take( + Stopwatch.GetTimestamp(), + m_prims, + m_discrete, + m_timeSamples, + m_scalars, + m_transforms); + } + if (snapshot.IsEmpty) + { + return; + } + try + { + await m_scheduler.EditAsync( + stage => Apply(stage, snapshot), + snapshot.Invalidation, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The viewport closed while an edit was in flight. + } + catch (ObjectDisposedException) + { + // The stage was torn down while an edit was in flight. + } +#pragma warning disable CA1031 // A malformed server value must not kill the pump. + catch (Exception exception) +#pragma warning restore CA1031 + { + m_onError(exception); + } + } + + private static void Apply(UsdStage stage, Snapshot snapshot) + { + foreach (KeyValuePair entry in snapshot.Prims) + { + ApplyComposition(stage, entry.Key, entry.Value); + } + foreach (TransformSample entry in snapshot.Transforms) + { + ApplyTransform(stage, entry); + } + foreach (ScalarSample entry in snapshot.Scalars) + { + UsdPrim prim = stage.GetPrim(entry.PrimPath); + if (prim.Exists()) + { + prim.SetDouble(entry.PropertyName, entry.Value); + } + } + foreach (KeyValuePair entry in snapshot.Discrete) + { + ApplyValueIsolated( + stage.GetPrim(entry.Key.PrimPath), + entry.Key.PrimPath, + entry.Key.PropertyName, + entry.Value, + timeCode: null); + } + foreach (KeyValuePair entry in snapshot.TimeSamples) + { + ApplyValueIsolated( + stage.GetPrim(entry.Key.PrimPath), + entry.Key.PrimPath, + entry.Key.PropertyName, + entry.Value, + entry.Key.Frame); + } + } + + /// + /// Applies one attribute, containing any failure to that attribute. A + /// single unwritable target (for example a type the stage rejects) must + /// not discard the whole frame, which would freeze every other binding. + /// Each distinct target is reported once so a persistent mismatch cannot + /// flood the console. + /// + private static void ApplyValueIsolated( + UsdPrim prim, string primPath, string propertyName, Variant value, double? timeCode) + { + try + { + ApplyValue(prim, propertyName, value, timeCode); + } +#pragma warning disable CA1031 // One rejected attribute must not discard the frame. + catch (Exception exception) +#pragma warning restore CA1031 + { + string target = string.Concat(primPath, ".", propertyName); + if (s_rejectedTargets.TryAdd(target, true)) + { + Console.Error.WriteLine( + $"A stage update failed for {target}: {exception.Message} " + + "Further failures for this target are suppressed."); + } + } + } + + private static void ApplyComposition( + UsdStage stage, string primPath, PrimComposition composition) + { + UsdPrim prim = stage.GetPrim(primPath); + if (!prim.Exists()) + { + prim = stage.DefinePrim(primPath, "Xform"); + } + if (!string.IsNullOrEmpty(composition.AssetReference) && + TryParseReference(composition.AssetReference!, out string asset, out string? target)) + { + switch (composition.Arc) + { + case OpenUsdCompositionArc.Reference: + case OpenUsdCompositionArc.Instance: + prim.ClearReferences(); + prim.AddReference(asset, target); + prim.SetInstanceable( + composition.Arc == OpenUsdCompositionArc.Instance); + break; + case OpenUsdCompositionArc.Payload: + prim.ClearPayloads(); + prim.AddPayload(asset, target); + break; + default: + break; + } + } + prim.SetActive(composition.Active); + } + + /// + /// Splits a USD reference literal such as @robot.usda@</Robot> into the + /// asset path and the optional target prim path. The connector hands sinks the + /// literal because the file sink writes it straight into a layer; authoring + /// through the data API needs the two parts separately. + /// + private static bool TryParseReference( + string reference, out string assetPath, out string? targetPrimPath) + { + assetPath = string.Empty; + targetPrimPath = null; + string value = reference.Trim(); + if (value.Length < 3 || value[0] != '@') + { + return false; + } + int close = value.IndexOf('@', 1); + if (close <= 1) + { + return false; + } + assetPath = value.Substring(1, close - 1); + string rest = value[(close + 1)..].Trim(); + if (rest.Length == 0) + { + return true; + } + if (rest.Length < 3 || rest[0] != '<' || rest[^1] != '>') + { + return false; + } + targetPrimPath = rest[1..^1]; + return UsdNames.IsValidPrimPath(targetPrimPath); + } + + private static void ApplyTransform(UsdStage stage, TransformSample sample) + { + UsdPrim prim = stage.GetPrim(sample.PrimPath); + if (!prim.Exists() || !UsdGeomXformable.TryWrap(prim, out UsdGeomXformable xformable)) + { + return; + } + UsdVec3d translation = sample.Translate ?? + xformable.GetLocalTransform().ExtractTranslation(); + UsdVec3d rotation = sample.Rotate ?? new UsdVec3d(0, 0, 0); + UsdVec3d scale = sample.Scale ?? new UsdVec3d(1, 1, 1); + // Author the matrix op directly rather than through SetLocalTransform. That + // helper rewrites xformOpOrder, which fails whenever the asset declares an op + // order in a layer weaker than the one being edited: the opinion there cannot + // be cleared from here. Bound prims therefore declare a single + // xformOp:transform, which only has to be set. + prim.SetMatrix4d("xformOp:transform", Compose(translation, rotation, scale)); + } + + /// + /// Builds the row-vector local transform for scale, then rotation (XYZ degrees), + /// then translation - the composition OpenUSD's + /// ["xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale"] op order + /// denotes. + /// + private static UsdMatrix4d Compose(UsdVec3d translate, UsdVec3d rotate, UsdVec3d scale) + { + const double toRadians = Math.PI / 180.0; + double cx = Math.Cos(rotate.X * toRadians); + double sx = Math.Sin(rotate.X * toRadians); + double cy = Math.Cos(rotate.Y * toRadians); + double sy = Math.Sin(rotate.Y * toRadians); + double cz = Math.Cos(rotate.Z * toRadians); + double sz = Math.Sin(rotate.Z * toRadians); + + // Row-vector rotation R = Rx * Ry * Rz. + double r00 = cy * cz; + double r01 = cy * sz; + double r02 = -sy; + double r10 = (sx * sy * cz) - (cx * sz); + double r11 = (sx * sy * sz) + (cx * cz); + double r12 = sx * cy; + double r20 = (cx * sy * cz) + (sx * sz); + double r21 = (cx * sy * sz) - (sx * cz); + double r22 = cx * cy; + + return new UsdMatrix4d( + scale.X * r00, scale.X * r01, scale.X * r02, 0, + scale.Y * r10, scale.Y * r11, scale.Y * r12, 0, + scale.Z * r20, scale.Z * r21, scale.Z * r22, 0, + translate.X, translate.Y, translate.Z, 1); + } + + private static bool IsColorProperty(string propertyName) + { + return propertyName.EndsWith("Color", StringComparison.Ordinal) || + propertyName.EndsWith("color", StringComparison.Ordinal); + } + + private static void ApplyValue( + UsdPrim prim, string propertyName, Variant value, double? timeCode) + { + if (!prim.Exists()) + { + return; + } + if (value.TryGetValue(out ArrayOf floats) && floats.Count == 3) + { + var vector = new UsdVec3f(floats[0], floats[1], floats[2]); + if (propertyName.EndsWith("displayColor", StringComparison.Ordinal)) + { + ReadOnlySpan single = [vector]; + if (timeCode is { } colorTime) + { + prim.SetVec3fArray(propertyName, single, colorTime); + } + else + { + prim.SetVec3fArray(propertyName, single); + } + return; + } + // Shader colour inputs are authored as color3f, and the native + // setters match the attribute type exactly -- writing them as a + // plain float3 is rejected. + if (IsColorProperty(propertyName)) + { + if (timeCode is { } shaderColorTime) + { + prim.SetColor3f(propertyName, vector, shaderColorTime); + } + else + { + prim.SetColor3f(propertyName, vector); + } + return; + } + if (timeCode is { } vectorTime) + { + prim.SetVec3f(propertyName, vector, vectorTime); + } + else + { + prim.SetVec3f(propertyName, vector); + } + return; + } + if (value.TryGetValue(out string? text) && text is not null) + { + if (string.Equals(propertyName, "visibility", StringComparison.Ordinal)) + { + prim.SetVisibility(text); + return; + } + if (timeCode is { } tokenTime) + { + prim.SetToken(propertyName, text, tokenTime); + } + else + { + prim.SetToken(propertyName, text); + } + return; + } + if (value.TryGetValue(out bool flag)) + { + if (timeCode is { } boolTime) + { + prim.SetBool(propertyName, flag, boolTime); + } + else + { + prim.SetBool(propertyName, flag); + } + return; + } + if (VariantConversions.TryGetDouble(value, out double number)) + { + if (timeCode is { } numberTime) + { + prim.SetDouble(propertyName, number, numberTime); + } + else + { + prim.SetDouble(propertyName, number); + } + } + } + + /// + /// Retargets a numeric attribute so it eases towards the newest sample instead of + /// stepping onto it. Returns false for anything that is not a plain number. + /// + private bool TryUpdateScalar( + string primPath, string propertyName, in Variant value, long now) + { + if (value.TryGetValue(out string? _) || value.TryGetValue(out bool _) || + value.TryGetValue(out ArrayOf _) || + !VariantConversions.TryGetDouble(value, out double number)) + { + return false; + } + var key = new AttributeKey(primPath, propertyName); + if (m_scalars.TryGetValue(key, out ScalarTrack? track)) + { + track.Retarget(number, now); + } + else + { + m_scalars[key] = ScalarTrack.Starting(number, now); + } + return true; + } + + /// + /// Routes a structured double3 transform op into the per-prim transform + /// tracks. Returns false for anything that is authorable directly, such as + /// the scalar xformOp:rotateZ a robot joint uses. + /// + private bool TryUpdateTransform( + string primPath, string propertyName, in Variant value, long now) + { + if (!string.Equals(propertyName, TranslateOp, StringComparison.Ordinal) && + !string.Equals(propertyName, RotateOp, StringComparison.Ordinal) && + !string.Equals(propertyName, ScaleOp, StringComparison.Ordinal)) + { + return false; + } + if (!TryGetVec3d(value, out UsdVec3d vector)) + { + return false; + } + if (!m_transforms.TryGetValue(primPath, out TransformTracks? tracks)) + { + tracks = new TransformTracks(); + m_transforms[primPath] = tracks; + } + tracks.Retarget(propertyName, vector, now); + return true; + } + + private static bool TryGetVec3d(in Variant value, out UsdVec3d result) + { + if (value.TryGetValue(out ArrayOf doubles) && doubles.Count == 3) + { + result = new UsdVec3d(doubles[0], doubles[1], doubles[2]); + return true; + } + if (value.TryGetValue(out ArrayOf floats) && floats.Count == 3) + { + result = new UsdVec3d(floats[0], floats[1], floats[2]); + return true; + } + result = default; + return false; + } + + /// + /// One numeric target easing from the value it had when the newest sample arrived + /// towards that sample, across roughly the observed sample interval. + /// + private sealed class ScalarTrack + { + private double m_from; + private double m_to; + private long m_start; + private long m_window; + private double m_lastApplied; + private bool m_applied; + + public static ScalarTrack Starting(double value, long now) => + new() { m_from = value, m_to = value, m_start = now, m_window = 0 }; + + public void Retarget(double value, long now) + { + m_from = ValueAt(now); + m_to = value; + m_window = Math.Clamp(now - m_start, s_minWindow, s_maxWindow); + m_start = now; + } + + public double ValueAt(long now) + { + if (m_window <= 0) + { + return m_to; + } + double u = (now - m_start) / (double)m_window; + if (u >= 1.0) + { + return m_to; + } + return u <= 0.0 ? m_from : m_from + ((m_to - m_from) * u); + } + + /// + /// Returns the value to author, or null when the target has settled and + /// the stage already carries it, so a still scene costs no stage edits. + /// + public double? Pending(long now) + { + double value = ValueAt(now); + if (m_applied && value == m_lastApplied) + { + return null; + } + m_lastApplied = value; + m_applied = true; + return value; + } + } + + /// + /// The translate, rotate and scale tracks of one prim's composed local transform. + /// + private sealed class TransformTracks + { + private Vec3Track? m_translate; + private Vec3Track? m_rotate; + private Vec3Track? m_scale; + + public void Retarget(string op, UsdVec3d value, long now) + { + switch (op) + { + case TranslateOp: + Retarget(ref m_translate, value, now); + break; + case RotateOp: + Retarget(ref m_rotate, value, now); + break; + default: + Retarget(ref m_scale, value, now); + break; + } + } + + public bool TryTake(string primPath, long now, out TransformSample sample) + { + bool changed = + (m_translate?.HasPending(now) ?? false) | + (m_rotate?.HasPending(now) ?? false) | + (m_scale?.HasPending(now) ?? false); + sample = new TransformSample( + primPath, + m_translate?.ValueAt(now), + m_rotate?.ValueAt(now), + m_scale?.ValueAt(now)); + return changed; + } + + private static void Retarget(ref Vec3Track? track, UsdVec3d value, long now) + { + if (track is null) + { + track = Vec3Track.Starting(value, now); + return; + } + track.Retarget(value, now); + } + } + + private sealed class Vec3Track + { + private readonly ScalarTrack m_x = ScalarTrack.Starting(0, 0); + private readonly ScalarTrack m_y = ScalarTrack.Starting(0, 0); + private readonly ScalarTrack m_z = ScalarTrack.Starting(0, 0); + + public static Vec3Track Starting(UsdVec3d value, long now) + { + var track = new Vec3Track(); + track.Retarget(value, now); + return track; + } + + public void Retarget(UsdVec3d value, long now) + { + m_x.Retarget(value.X, now); + m_y.Retarget(value.Y, now); + m_z.Retarget(value.Z, now); + } + + public UsdVec3d ValueAt(long now) => + new(m_x.ValueAt(now), m_y.ValueAt(now), m_z.ValueAt(now)); + + public bool HasPending(long now) + { + // Evaluate all three so each track records what it last authored. + bool x = m_x.Pending(now).HasValue; + bool y = m_y.Pending(now).HasValue; + bool z = m_z.Pending(now).HasValue; + return x || y || z; + } + } + + private readonly record struct AttributeKey(string PrimPath, string PropertyName); + + private readonly record struct TimeSampleKey( + string PrimPath, string PropertyName, double Frame); + + private readonly record struct PrimComposition( + OpenUsdCompositionArc Arc, string? AssetReference, bool Active); + + private readonly record struct ScalarSample( + string PrimPath, string PropertyName, double Value); + + private readonly record struct TransformSample( + string PrimPath, UsdVec3d? Translate, UsdVec3d? Rotate, UsdVec3d? Scale); + + /// + /// One tick's worth of work, detached from the sink's mutable state so the stage + /// callback never touches a lock. + /// + private sealed class Snapshot + { + private Snapshot( + Dictionary prims, + Dictionary discrete, + Dictionary timeSamples, + List scalars, + List transforms) + { + Prims = prims; + Discrete = discrete; + TimeSamples = timeSamples; + Scalars = scalars; + Transforms = transforms; + } + + public Dictionary Prims { get; } + + public Dictionary Discrete { get; } + + public Dictionary TimeSamples { get; } + + public List Scalars { get; } + + public List Transforms { get; } + + public bool IsEmpty => + Prims.Count == 0 && Discrete.Count == 0 && + TimeSamples.Count == 0 && Scalars.Count == 0 && Transforms.Count == 0; + + /// + /// Composing a prim changes the stage's composition; everything else only + /// changes property values, which is far cheaper for the renderer to absorb. + /// + public UsdStageInvalidationKind Invalidation => Prims.Count > 0 + ? UsdStageInvalidationKind.Composition + : UsdStageInvalidationKind.Property; + + public static Snapshot Take( + long now, + Dictionary prims, + Dictionary discrete, + Dictionary timeSamples, + Dictionary scalars, + Dictionary transforms) + { + var scalarSamples = new List(); + foreach (KeyValuePair entry in scalars) + { + if (entry.Value.Pending(now) is { } value) + { + scalarSamples.Add( + new ScalarSample(entry.Key.PrimPath, entry.Key.PropertyName, value)); + } + } + + var transformSamples = new List(); + foreach (KeyValuePair entry in transforms) + { + if (entry.Value.TryTake(entry.Key, now, out TransformSample sample)) + { + transformSamples.Add(sample); + } + } + + var snapshot = new Snapshot( + new Dictionary(prims, StringComparer.Ordinal), + new Dictionary(discrete), + new Dictionary(timeSamples), + scalarSamples, + transformSamples); + prims.Clear(); + discrete.Clear(); + timeSamples.Clear(); + // Interpolation tracks are retained: they keep easing across ticks and + // report nothing once they have settled. + return snapshot; + } + } + + private sealed class BatchScope : IDisposable + { + private UsdStageSink? m_owner; + + public BatchScope(UsdStageSink owner) + { + m_owner = owner; + } + + public void Dispose() + { + UsdStageSink? owner = Interlocked.Exchange(ref m_owner, null); + owner?.EndBatch(); + } + } + } + + /// + /// Prim-path and property-name validation. The binding model comes from the server, + /// which the connector treats as untrusted, so a malformed or hostile name is dropped + /// rather than passed to the USD runtime. + /// + internal static class UsdNames + { + public static bool IsValidPrimPath(string? path) + { + if (string.IsNullOrEmpty(path) || path[0] != '/' || path.Length == 1) + { + return false; + } + foreach (string segment in path.Split('/')) + { + if (segment.Length == 0) + { + continue; + } + if (!IsValidIdentifier(segment)) + { + return false; + } + } + return true; + } + + public static bool IsValidPropertyName(string? name) + { + if (string.IsNullOrEmpty(name)) + { + return false; + } + foreach (string segment in name.Split(':')) + { + if (!IsValidIdentifier(segment)) + { + return false; + } + } + return true; + } + + private static bool IsValidIdentifier(string segment) + { + if (segment.Length == 0) + { + return false; + } + if (segment[0] != '_' && !char.IsLetter(segment[0])) + { + return false; + } + foreach (char character in segment) + { + if (character != '_' && + !char.IsLetterOrDigit(character)) + { + return false; + } + } + return true; + } + } +} diff --git a/tools/Opc.Ua.OpenUsd.Connector/NugetREADME.md b/tools/Opc.Ua.OpenUsd.Connector/NugetREADME.md new file mode 100644 index 0000000000..57cce0a96c --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector/NugetREADME.md @@ -0,0 +1,186 @@ +# Opc.Ua.OpenUsd.Connector + +A standalone, **generic OPC UA โ†’ OpenUSD** connector tool (the *connector* actor of the draft +**OPC UA โ€” OpenUSD Bindings** companion specification), built on **Opc.Ua.OpenUsd.Client**. It +connects to any OPC UA server that implements the companion model, discovers the OpenUSD +representation and its live bindings from the server's address space, subscribes to the bound +Variables, converts the values, and authors a live USD override layer (`live.usda`). Composed +over a base USD asset, that layer drives a twin **rendered with live data** in `usdview` or +NVIDIA Omniverse โ€” with **no domain-specific code** in the connector or the renderer. + +The walkthrough below uses the `PumpDeviceIntegrationServer` sample; the same binary works +unchanged against the `MinimalRobotServer` sample or any other conforming server. + +## What it does + +``` +PumpDeviceIntegrationServer (OPC UA server: PumpType + OpenUSD bindings) + โ”‚ Server/OpenUSD/Representations (discovery) + โ–ผ +Opc.Ua.OpenUsd.Connector โ”€โ”€โ–บ UsdFileSink โ”€โ”€โ–บ live.usda (runtime override layer) + โ”‚ subLayers + โ–ผ + stage.usda = live.usda + Plant.usda + โ”‚ + โ–ผ + usdview / NVIDIA Omniverse (the rendered live pump) +``` + +The bridge starts at the well-known `Server/OpenUSD/Representations` registry (Part 1 +discovery), reads each `OpenUsdLiveBinding` (`SourceNodeId`, target prim/property, +`RenderTargetKind`, `Scale`), subscribes, applies the conversion, and writes the target +USD attribute. It never needs to know "pump" โ€” the same binary works for any conforming +server. + +## The three live bindings (pump sample) + +| Source (Pump measurement) | USD target | Kind | Visible effect | +|---|---|---|---| +| `MassFlow` | `/Plant/Pumps/P101/Impeller` ยท `xformOp:rotateZ` | Rotation | impeller angle | +| `BearingTemperature` | `/Plant/Pumps/P101/Body` ยท `primvars:displayColor` | DisplayColor | body colour (blueโ†’red) | +| `DifferentialPressure` | `/Plant/Pumps/P101/StatusLight/Mat/Surface` ยท `inputs:emissiveColor` | EmissiveColor | status-light glow | + +Two further bindings exercise the rest of the model: an **alarm** binding (`OpenUsdAlarmBindingType`) +drives `/Plant/Pumps/P101/StatusLight` ยท `visibility` from the supervision alarm, and an +**opt-in command** binding (`OpenUsdCommandBindingType`) writes a USD `inputs:speedSetpoint` intent back +into the server's `SpeedSetpoint` Variable. The stage also advertises a `RootLayerDigest` +(`Sha256`) the bridge verifies before composing (Twin-BOM content integrity). + +## Prerequisites + +- **.NET SDK 10** โ€” to build and run the server + bridge. +- A USD viewer: **usdview** (from a full OpenUSD build or NVIDIA Omniverse's `usdview`), or + **NVIDIA Omniverse** (USD Composer / Kit) for an RTX render with continuous `.live` + updates. The `usd-core` PyPI wheel provides the `pxr` Python modules for validation but + **not** the `usdview` GUI. +- The base USD asset (`Plant.usda`) and composed stage (`stage.usda`) from the companion + spec repo: `marcschier/opcua-drafts` โ†’ + `core-specs/extras/openusd-binding/examples/pumps/`. + +## Run it end-to-end + +### 1. Build + +```bash +dotnet build samples/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj -c Release -f net10.0 +dotnet build tools/Opc.Ua.OpenUsd.Connector/Opc.Ua.OpenUsd.Connector.csproj -c Release -f net10.0 +``` + +### 2. Prepare a working folder with the base asset + +```bash +mkdir ~/pump-live +cp /core-specs/extras/openusd-binding/examples/pumps/Plant.usda ~/pump-live/ +cp /core-specs/extras/openusd-binding/examples/pumps/stage.usda ~/pump-live/ +``` + +`stage.usda` sublayers `live.usda` (stronger) over `Plant.usda`. + +### 3. Start the server (terminal 1) + +```bash +dotnet run --project samples/PumpDeviceIntegrationServer -c Release -f net10.0 -- --host localhost --port 62810 +``` + +Wait for `OPC UA server listening at opc.tcp://localhost:62810/PumpDeviceIntegrationServer.` +The pump simulation drives `MassFlow`, `BearingTemperature`, and `DifferentialPressure`, +which is what makes the render *live*. + +### 4. Run the connector (terminal 2) + +```bash +dotnet run --project tools/Opc.Ua.OpenUsd.Connector -c Release -f net10.0 -- \ + --server opc.tcp://localhost:62810/PumpDeviceIntegrationServer --out ~/pump-live/live.usda --insecure +``` + +Options: `--server ` (default `opc.tcp://localhost:62542/PumpDeviceIntegrationServer`), +`--out `, `--seconds N` (run for a fixed time instead of until Ctrl+C), and +`--insecure`. The connector is **secure by default** (encrypted, signed channel with server-certificate +trust required, per spec ยง9). `--insecure` opts into an unsecured endpoint and blanket certificate +acceptance โ€” appropriate only for this localhost demo, whose server uses a self-signed certificate. +Omit `--insecure` and place the server certificate in the bridge's trusted store for a secured run. + +### Rendering the twin in the same process + +Steps 5 onward assume an external viewer. Install the optional +`OPCFoundation.NetStandard.Opc.Ua.OpenUsd.Connector.Viewer` package alongside the connector and +`--view` renders the composed stage itself, streaming the same subscribed values into the picture on +screen as well as into `live.usda`: + +```bash +dotnet run --project tools/Opc.Ua.OpenUsd.Connector -c Release -f net10.0 -- \ + --server opc.tcp://localhost:62830/MinimalRobotServer --insecure --view +``` + +Without `--fetch-assets` or `--stage`, `--view` fetches the server's asset closure into a temporary +directory first, because a renderer needs geometry it can resolve. `--renderer` selects the backend +and `--plugins` points at the USD plugin tree when it is not beside the connector. Closing the window +stops the session; `--seconds` closes it automatically. The connector loads the viewport assembly on +demand, so the package stays small and every other option keeps working without it. The viewport +requires .NET 10 on `win-x64`. + + +Command bindings are **disabled by default** (fail-closed). Add `--enable-commands` and +`--command-value ` to actuate the single `OpenUsdCommandBindingType` binding once at start, e.g. +`--enable-commands --command-value 1450` writes `1450` into the server's `SpeedSetpoint` +Variable (single-writer, authorized, fail-closed). Read-only telemetry/alarm flow is unaffected. + +It rewrites `live.usda` on every value change, e.g.: + +```usda +over "Plant" { over "Pumps" { over "P101" { + over "Impeller" { double xformOp:rotateZ = 0.0538 } + over "Body" { color3f[] primvars:displayColor = [(1.0000, 0.0000, 0.0000)] } + over "StatusLight" { over "Mat" { over "Surface" { + color3f inputs:emissiveColor = (0.1000, 1.0000, 0.2000) } } } +} } } +``` + +### 5. Open the rendered live pump + +- **usdview:** `usdview ~/pump-live/stage.usda` โ€” a cylindrical body, a two-blade impeller, + and a status light. usdview loads a snapshot; press **`R`** (Reload All Layers) to pull + the bridge's latest values. +- **NVIDIA Omniverse:** open `~/pump-live/stage.usda` in USD Composer โ€” the override layer + updates the viewport **continuously** under RTX (impeller spins, body warms toward red, + status light glows). + +> The emissive glow needs a material-aware renderer (Omniverse RTX, or usdview with Storm); +> `displayColor` and the transform render everywhere. + +## Validate composition without a GUI + +With only `usd-core` installed: + +```bash +python - <<'PY' +from pxr import Usd, UsdGeom +s = Usd.Stage.Open("stage.usda") +g = lambda p, a: s.GetPrimAtPath(p).GetAttribute(a).Get() +print("rotateZ :", g("/Plant/Pumps/P101/Impeller", "xformOp:rotateZ")) +print("displayColor:", g("/Plant/Pumps/P101/Body", "primvars:displayColor")) +print("emissive :", g("/Plant/Pumps/P101/StatusLight/Mat/Surface", "inputs:emissiveColor")) +PY +``` + +## How it maps to the specification + +- **Discovery** โ€” the bridge starts at `Server/OpenUSD/Representations` (base spec ยง4.2), + never at "the pump". One binary serves any conforming server. +- **Bindings** โ€” each `OpenUsdLiveBinding` declares `SourceNodeId`, target prim/property, + `RenderTargetKind`, and `Scale`; the bridge reads them and applies the conversion + (ยง5.7โ€“ยง5.8). 0.2 adds `SignalRole`, `SourceSemanticId`, alarm (`OpenUsdAlarmBindingType`/`AlarmAspect`), + and opt-in command (`OpenUsdCommandBindingType`/`CommandTargetNodeId`) members. +- **Integrity** โ€” the stage's `RootLayerDigest`/`RootLayerDigestAlgorithm` are verified before + composition (Twin-BOM content integrity, ยง5.11/ยง9); a mismatch is fail-closed. +- **Command safety** โ€” command bindings are normative but opt-in: disabled by default, + single-writer, authorized, fail-closed (ยง5.10). +- **Composition** โ€” the bridge assembles the asset's component hierarchy into USD prims + (`OpenUsdComponentBindingType`, ยง5.12โ€“5.14): 1:1 child prims, 1..n instanceable references, + dynamic add/remove reconciled from model-change events, and cross-server federation. +- **Layering** โ€” OPC UA is the single mapping authority; the base USD asset is never + modified. Live values live in a composed override layer (the equivalent of an Omniverse + Nucleus `.live` layer, Part 3). + +The generic companion specification and the full step-by-step guide live in +`marcschier/opcua-drafts` under `core-specs/openusd-binding/`. diff --git a/tools/Opc.Ua.OpenUsd.Connector/Opc.Ua.OpenUsd.Connector.csproj b/tools/Opc.Ua.OpenUsd.Connector/Opc.Ua.OpenUsd.Connector.csproj new file mode 100644 index 0000000000..fbbe9dcb5e --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector/Opc.Ua.OpenUsd.Connector.csproj @@ -0,0 +1,26 @@ + + + $(AppTargetFrameworks) + Exe + true + Opc.Ua.OpenUsd.Connector + $(PackagePrefix).Opc.Ua.OpenUsd.Connector + NugetREADME.md + Generic OPC UA - OpenUSD connector tool. Connects to any server implementing the draft OPC UA - OpenUSD Bindings companion model, discovers Server/OpenUSD/Representations, subscribes to the bound Variables, optionally fetches the served USD asset closure (spec ยง5.15), and authors a live USD override layer (live.usda) plus an optional self-contained stage. Built on Opc.Ua.OpenUsd.Client. + Opc.Ua.OpenUsd.Connector + enable + + app.manifest + + + + + + + + + + + + diff --git a/tools/Opc.Ua.OpenUsd.Connector/OpenUsdConnectorRunner.cs b/tools/Opc.Ua.OpenUsd.Connector/OpenUsdConnectorRunner.cs new file mode 100644 index 0000000000..6b3c62df25 --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector/OpenUsdConnectorRunner.cs @@ -0,0 +1,515 @@ +/* ======================================================================== + * 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.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.OpenUsd.Client; + +namespace Opc.Ua.OpenUsd.Connector +{ + /// + /// Runs the generic : connects to a running OPC UA + /// server (e.g. MinimalRobotServer or PumpDeviceIntegrationServer), discovers the + /// OpenUSD representation and bindings via Server/OpenUSD/Representations, and + /// streams live values into a (an override live.usda). + /// With --view it additionally opens a viewport on the composed stage and fans + /// the same values into it, so the twin animates on screen in the same process. + /// Invoked as Opc.Ua.OpenUsd.Connector [--server <url>] [--out <live.usda>] + /// [--seconds N] [--view] [--renderer <Auto|Storm|D3D12|Vulkan>]. + /// + public static class OpenUsdConnectorRunner + { + public static async Task RunAsync(string[] args) + { + string server = GetOption(args, "--server") + ?? "opc.tcp://localhost:62542/PumpDeviceIntegrationServer"; + string outPath = GetOption(args, "--out") ?? Path.Combine(Environment.CurrentDirectory, "live.usda"); + int seconds = int.TryParse(GetOption(args, "--seconds"), out int s) ? s : 0; + + // Opens a viewport on the composed stage and streams the same values into it. + // The renderer lives in the optional Opc.Ua.OpenUsd.Connector.Viewer assembly, + // so the connector itself stays free of Avalonia and the native USD payload. + bool view = HasFlag(args, "--view"); + string? renderer = GetOption(args, "--renderer"); + string? stageOption = GetOption(args, "--stage"); + string? pluginPath = GetOption(args, "--plugins"); + string? cameraPath = GetOption(args, "--camera"); + + // ยง5.15 asset content delivery (OU-AssetDelivery): when set, the connector + // downloads the server's served USD layer closure into this cache directory + // (verifying each digest) and writes a self-contained stage.usda there, so a + // viewer renders the twin with no external asset resolver. live.usda is + // written into the same directory. + string? cacheDir = GetOption(args, "--fetch-assets"); + if (view && string.IsNullOrEmpty(cacheDir) && string.IsNullOrEmpty(stageOption)) + { + // Rendering needs a resolvable asset closure. Without an explicit stage the + // connector fetches one rather than opening an empty viewport. + cacheDir = Path.Combine(GetPrivateStateRoot(), "stage"); + } + if (!string.IsNullOrEmpty(cacheDir)) + { + Directory.CreateDirectory(cacheDir!); + outPath = Path.Combine(cacheDir!, "live.usda"); + } + + // Secure by default (spec ยง9: an authenticated, integrity-protected endpoint + // with server-certificate trust is required). The --insecure flag opts into + // an unsecured endpoint and blanket certificate acceptance, which is only + // appropriate for a localhost demo with self-signed certificates. + bool insecure = HasFlag(args, "--insecure"); + + // Command bindings (UsdToUaCommand) are opt-in and disabled by default + // (fail-closed). --enable-commands lets the connector actuate the single + // controllable command binding; --command-value supplies the + // setpoint to write once at start (demo). + bool enableCommands = HasFlag(args, "--enable-commands"); + string? commandValueOpt = GetOption(args, "--command-value"); + + ITelemetryContext telemetry = DefaultTelemetry.Create(b => b.SetMinimumLevel(LogLevel.Warning)); + + string pkiRoot = Path.Combine(GetPrivateStateRoot(), Path.GetRandomFileName()); + var config = new ApplicationConfiguration(telemetry) + { + ApplicationName = "Opc.Ua.OpenUsd.Connector", + ApplicationUri = "urn:localhost:OPCFoundation:Opc.Ua.OpenUsd.Connector", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "own"), + SubjectName = "CN=Opc.Ua.OpenUsd.Connector, O=OPC Foundation" + }, + TrustedIssuerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "issuer") + }, + TrustedPeerCertificates = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "trusted") + }, + RejectedCertificateStore = new CertificateTrustList + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pkiRoot, "rejected") + }, + AutoAcceptUntrustedCertificates = insecure + }, + TransportQuotas = new TransportQuotas { MaxMessageSize = 4 * 1024 * 1024 }, + ClientConfiguration = new ClientConfiguration(), + ServerConfiguration = new ServerConfiguration() + }; + await config.ValidateAsync(ApplicationType.Client).ConfigureAwait(false); + + var appInstance = new Opc.Ua.Configuration.ApplicationInstance(config, telemetry); + await appInstance.CheckApplicationInstanceCertificatesAsync(true).ConfigureAwait(false); + await appInstance.DisposeAsync().ConfigureAwait(false); + config.CertificateManager ??= CertificateManagerFactory.Create(config.SecurityConfiguration, telemetry); + if (insecure) + { + // Demo-only: accept any server certificate. + config.CertificateManager.AcceptError = static (cert, err) => true; + Console.WriteLine("WARNING: --insecure: using an unsecured endpoint and accepting any server certificate."); + } + + Console.WriteLine($"Connecting to {server} ..."); + EndpointDescription? endpointDescription = null; + for (int attempt = 0; attempt < 40 && endpointDescription == null; attempt++) + { + try + { + endpointDescription = await CoreClientUtils.SelectEndpointAsync( + config, server, useSecurity: !insecure, telemetry, CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception) + { + await Task.Delay(500).ConfigureAwait(false); + } + } + if (endpointDescription == null) + { + Console.Error.WriteLine("ERROR: could not reach the server endpoint. Is the server running?"); + return 2; + } + + var endpoint = new ConfiguredEndpoint(null, endpointDescription, EndpointConfiguration.Create(config)); + var sessionFactory = new DefaultSessionFactory(telemetry); + ISession session = await sessionFactory.CreateAsync( + config, endpoint, updateBeforeConnect: false, + sessionName: "Opc.Ua.OpenUsd.Connector", sessionTimeout: 60000, + identity: new UserIdentity(new AnonymousIdentityToken()), + preferredLocales: default, ct: CancellationToken.None).ConfigureAwait(false); + + var fileSink = new UsdFileSink(outPath); + string? stagePath = stageOption; + IUsdViewHost? viewHost = null; + if (view && !UsdViewHostLoader.TryLoad(out viewHost, out string unavailable)) + { + Console.Error.WriteLine($"ERROR: the view option is unavailable. {unavailable}"); + await CloseAsync(session, config).ConfigureAwait(false); + return 3; + } + + // The asset closure is fetched before anything renders, because the viewport + // needs a stage it can resolve. Fetching does not use a sink, so a throwaway + // connector keeps the live sink entirely out of that path. + if (!string.IsNullOrEmpty(cacheDir)) + { + var fetcher = new OpenUsdConnector(session, new MockUsdSink(), enableCommands: false); + try + { + List fetched = + await fetcher.FetchServedAssetsAsync(cacheDir!, CancellationToken.None).ConfigureAwait(false); + if (fetched.Count > 0) + { + WriteStageUsda(cacheDir!, fetched); + stagePath ??= Path.Combine(cacheDir!, "stage.usda"); + Console.WriteLine( + $"Fetched {fetched.Count} server-delivered USD layer(s) into {cacheDir}; " + + "wrote a self-contained stage.usda."); + } + else + { + Console.WriteLine( + "Server does not advertise served assets (OU-AssetDelivery); using the external base asset."); + } + } + finally + { + await fetcher.DisposeAsync().ConfigureAwait(false); + } + } + + int exit = view + ? await RunViewportAsync( + viewHost!, stagePath, renderer, pluginPath, cameraPath, session, fileSink, + enableCommands, commandValueOpt, seconds, outPath).ConfigureAwait(false) + : await RunHeadlessAsync( + session, fileSink, enableCommands, commandValueOpt, seconds, outPath) + .ConfigureAwait(false); + + await CloseAsync(session, config).ConfigureAwait(false); + Console.WriteLine($"Stopped. Final override layer: {outPath}"); + return exit; + } + + /// + /// Streams into the override layer only, until Ctrl+C or the requested duration. + /// + private static async Task RunHeadlessAsync( + ISession session, + IUsdSink sink, + bool enableCommands, + string? commandValueOpt, + int seconds, + string outPath) + { + var connector = new OpenUsdConnector(session, sink, enableCommands); + try + { + await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + Console.WriteLine($"Streaming live OPC UA values into {outPath}. Press Ctrl+C to stop."); + await IssueCommandIfRequestedAsync( + connector, enableCommands, commandValueOpt, CancellationToken.None) + .ConfigureAwait(false); + + using var stop = new SemaphoreSlim(0, 1); + ConsoleCancelEventHandler handler = (_, e) => { e.Cancel = true; stop.Release(); }; + Console.CancelKeyPress += handler; + try + { + if (seconds > 0) + { + await Task.Delay(TimeSpan.FromSeconds(seconds)).ConfigureAwait(false); + } + else + { + await stop.WaitAsync().ConfigureAwait(false); + } + } + finally + { + Console.CancelKeyPress -= handler; + } + + await connector.StopAsync().ConfigureAwait(false); + } + finally + { + await connector.DisposeAsync().ConfigureAwait(false); + } + return 0; + } + + /// + /// Opens the viewport on a dedicated UI thread and streams into both the override + /// layer and the rendered stage. Completes when the window closes. + /// + /// + /// The viewport owns whichever thread runs it, and on Windows that thread must be + /// single-threaded-apartment. Running it on a thread of its own rather than on the + /// process main thread keeps this method genuinely asynchronous, so no caller ever + /// blocks waiting on the window. + /// + private static Task RunViewportAsync( + IUsdViewHost host, + string? stagePath, + string? renderer, + string? pluginPath, + string? cameraPath, + ISession session, + IUsdSink fileSink, + bool enableCommands, + string? commandValueOpt, + int seconds, + string outPath) + { + if (string.IsNullOrEmpty(stagePath) || !File.Exists(stagePath)) + { + Console.Error.WriteLine( + "ERROR: there is no stage to render. The server does not deliver its USD " + + "assets, so pass --stage with a locally composed stage instead."); + return Task.FromResult(4); + } + + var options = new UsdViewOptions + { + StagePath = stagePath!, + PluginPath = pluginPath, + Renderer = renderer, + CameraPath = cameraPath, + Title = $"OPC UA - OpenUSD Connector - {Path.GetFileName(stagePath)}" + }; + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var uiThread = new Thread(() => + { + using var lifetime = new CancellationTokenSource(); + if (seconds > 0) + { + lifetime.CancelAfter(TimeSpan.FromSeconds(seconds)); + } + try + { + host.RunViewport( + options, + (stageSink, cancellationToken) => StreamAsync( + session, fileSink, stageSink, enableCommands, + commandValueOpt, outPath, cancellationToken), + lifetime.Token); + completion.TrySetResult(0); + } +#pragma warning disable CA1031 // Surfaced to the caller through the completion source. + catch (Exception exception) +#pragma warning restore CA1031 + { + Console.Error.WriteLine($"ERROR: the viewport failed: {exception.Message}"); + completion.TrySetResult(5); + } + }) + { + IsBackground = false, + Name = "OpenUSD viewport" + }; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + uiThread.SetApartmentState(ApartmentState.STA); + } + uiThread.Start(); + return completion.Task; + } + + /// + /// Runs the connector against both sinks until the viewport shuts down. + /// + private static async Task StreamAsync( + ISession session, + IUsdSink fileSink, + IUsdSink stageSink, + bool enableCommands, + string? commandValueOpt, + string outPath, + CancellationToken cancellationToken) + { + var sink = new CompositeUsdSink(fileSink, stageSink); + var connector = new OpenUsdConnector(session, sink, enableCommands); + try + { + await connector.StartAsync(cancellationToken).ConfigureAwait(false); + Console.WriteLine( + $"Streaming live OPC UA values into {outPath} and the viewport. " + + "Close the window to stop."); + await IssueCommandIfRequestedAsync( + connector, enableCommands, commandValueOpt, cancellationToken) + .ConfigureAwait(false); + await WaitForShutdownAsync(cancellationToken).ConfigureAwait(false); + // Shutdown runs after the token has already fired, so stopping cleanly + // must not itself be cancelled. + await connector.StopAsync(CancellationToken.None).ConfigureAwait(false); + } + finally + { + await connector.DisposeAsync().ConfigureAwait(false); + } + } + + private static async Task WaitForShutdownAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // The window closed or the duration elapsed; both are ordinary shutdowns. + } + } + + private static async Task IssueCommandIfRequestedAsync( + OpenUsdConnector connector, + bool enableCommands, + string? commandValueOpt, + CancellationToken cancellationToken) + { + if (!enableCommands || commandValueOpt == null + || !double.TryParse(commandValueOpt, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out double commandValue)) + { + return; + } + bool ok = await connector.IssueCommandAsync(commandValue, cancellationToken) + .ConfigureAwait(false); + Console.WriteLine(ok + ? $"Command issued: setpoint <- {commandValue}." + : "Command binding not found or write rejected."); + } + + private static async Task CloseAsync(ISession session, ApplicationConfiguration config) + { + await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); + await session.DisposeAsync().ConfigureAwait(false); + (config.CertificateManager as IDisposable)?.Dispose(); + } + + // Writes a self-contained stage.usda that composes the connector's live override + // layer over the server-delivered root layer (both now local in the cache dir). + private static void WriteStageUsda(string cacheDir, List fetched) + { + OpenUsdConnector.FetchedAsset? root = fetched.Find(a => a.Kind == OpenUsdAssetKind.RootLayer); + string rootName = root != null ? Path.GetFileName(root.LocalPath) : "base.usda"; + var sb = new StringBuilder(); + sb.Append("#usda 1.0\n(\n"); + sb.Append(" doc = \"Self-contained OpenUSD stage: server-delivered base layers + the live OPC UA override.\"\n"); + sb.Append(" subLayers = [\n @./live.usda@,\n @./").Append(rootName).Append("@\n ]\n"); + sb.Append(")\n"); + File.WriteAllText(Path.Combine(cacheDir, "stage.usda"), sb.ToString()); + + // The override layer is only written once the first values arrive, so seed an + // empty one now. Without it a viewer that opens the stage first reports the + // sublayer as missing before the connector has had anything to say. + string livePath = Path.Combine(cacheDir, "live.usda"); + if (!File.Exists(livePath)) + { + File.WriteAllText( + livePath, + "#usda 1.0\n(\n doc = \"OPC UA -> OpenUSD live bindings (override layer)\"\n)\n"); + } + } + + /// + /// Returns the per-user directory the connector keeps its asset cache + /// and PKI stores in, creating it if needed. + /// + /// + /// Deliberately not under : on POSIX that + /// is the shared, world-writable /tmp, so a fixed sub-path there + /// can be pre-created by another local user as a symlink. Everything the + /// connector writes into the asset cache is server-supplied content at + /// server-supplied relative paths, and the PKI root holds the client's + /// own private key plus its trusted-issuer store - redirecting either + /// would be serious. LocalApplicationData is per-user on every supported + /// platform. + /// + /// The private state directory. + private static string GetPrivateStateRoot() + { + string baseDirectory = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(baseDirectory)) + { + // A headless POSIX account may have neither XDG_DATA_HOME nor + // HOME; fall back to a directory beside the executable rather + // than to a shared temp path. + baseDirectory = AppContext.BaseDirectory; + } + string root = Path.Combine(baseDirectory, "Opc.Ua.OpenUsd.Connector"); + Directory.CreateDirectory(root); + return root; + } + + private static string? GetOption(string[] args, string name) + { + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) + { + return args[i + 1]; + } + } + return null; + } + + private static bool HasFlag(string[] args, string name) + { + foreach (string a in args) + { + if (string.Equals(a, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + } +} diff --git a/tools/Opc.Ua.OpenUsd.Connector/Program.cs b/tools/Opc.Ua.OpenUsd.Connector/Program.cs new file mode 100644 index 0000000000..eab917764e --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector/Program.cs @@ -0,0 +1,45 @@ +/* ======================================================================== + * 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.Threading.Tasks; +using Opc.Ua.OpenUsd.Connector; + +// Standalone OPC UA -> OpenUSD connector client. +// +// Usage: Opc.Ua.OpenUsd.Connector [--server ] [--out ] +// [--seconds N] [--fetch-assets ] [--insecure] +// [--enable-commands] [--command-value ] +// [--view] [--renderer ] [--stage ] +// [--plugins ] +// +// --view renders the composed stage and streams the same live values into it, so the +// twin animates on screen. It needs the optional Opc.Ua.OpenUsd.Connector.Viewer +// assembly alongside the connector; the runner hosts the viewport on its own dedicated +// UI thread, so this entry point stays fully asynchronous. +return await OpenUsdConnectorRunner.RunAsync(args).ConfigureAwait(false); diff --git a/tools/Opc.Ua.OpenUsd.Connector/Properties/AssemblyInfo.cs b/tools/Opc.Ua.OpenUsd.Connector/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..2b9848014c --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * 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; + +[assembly: CLSCompliant(false)] diff --git a/tools/Opc.Ua.OpenUsd.Connector/UsdViewHostLoader.cs b/tools/Opc.Ua.OpenUsd.Connector/UsdViewHostLoader.cs new file mode 100644 index 0000000000..66f8fc6445 --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector/UsdViewHostLoader.cs @@ -0,0 +1,187 @@ +/* ======================================================================== + * 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; +#if NET9_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif +using System.IO; +using System.Reflection; +#if NET8_0_OR_GREATER +using System.Runtime.InteropServices; +using System.Runtime.Loader; +#endif +using Opc.Ua.OpenUsd.Client; + +namespace Opc.Ua.OpenUsd.Connector +{ + /// + /// Finds the optional viewport assembly at run time. Keeping the renderer out of the + /// connector's own references is what lets the connector package stay small and keep + /// targeting .NET Framework, while the viewport needs .NET 10, Avalonia, and a + /// per-architecture native OpenUSD payload. + /// + internal static class UsdViewHostLoader + { +#if NET8_0_OR_GREATER + private const string ViewerAssemblyName = "Opc.Ua.OpenUsd.Connector.Viewer"; + private const string ViewerTypeName = + "Opc.Ua.OpenUsd.Connector.Viewer.OpenUsdViewHost"; +#endif + + /// + /// Loads the viewport implementation, or explains why it is unavailable. + /// + /// The loaded host, or null when unavailable. + /// A user-facing explanation when is null. + /// true when a host was loaded. + // The trim/AOT annotations are omitted on net8.0. The netstandard2.1 build + // variant pairs a net8.0 app with netstandard2.1 libraries, and those carry + // public RequiresUnreferencedCode/RequiresDynamicCode polyfills that collide + // with net8.0's own BCL definitions (CS0433). net8.0 is only ever an app + // target in that variant, and this tool is not trim- or AOT-published, so + // the annotations stay where they carry weight: net9.0 and later. +#if NET9_0_OR_GREATER + [RequiresUnreferencedCode( + "The viewport is discovered by assembly and type name, so trimming cannot see it.")] + [RequiresDynamicCode( + "The viewport is instantiated reflectively and is not available in a Native AOT publish.")] +#endif + public static bool TryLoad(out IUsdViewHost? host, out string reason) + { + host = null; +#if !NET8_0_OR_GREATER + reason = + "Rendering requires .NET 8 or later. Run the connector on a modern " + + "target framework to use the view option."; + return false; +#else + Assembly assembly; + try + { + assembly = LoadViewerAssembly(); + } + catch (Exception exception) when ( + exception is System.IO.FileNotFoundException or + System.IO.FileLoadException or BadImageFormatException) + { + reason = + $"The optional '{ViewerAssemblyName}' assembly was not found next to the " + + "connector. Install the matching " + + "OPCFoundation.NetStandard.Opc.Ua.OpenUsd.Connector.Viewer package, or run " + + "the connector without the view option to author the override layer only."; + return false; + } + + Type? type = assembly.GetType(ViewerTypeName, throwOnError: false); + if (type is null) + { + reason = + $"'{ViewerAssemblyName}' does not contain '{ViewerTypeName}'. The viewport " + + "package does not match this connector version."; + return false; + } + + object? instance; + try + { + instance = Activator.CreateInstance(type); + } + catch (MissingMethodException) + { + reason = $"'{ViewerTypeName}' has no public parameterless constructor."; + return false; + } + catch (TargetInvocationException exception) + { + reason = $"The viewport could not be created: {exception.InnerException?.Message}"; + return false; + } + + if (instance is not IUsdViewHost loaded) + { + reason = $"'{ViewerTypeName}' does not implement IUsdViewHost."; + return false; + } + + host = loaded; + reason = string.Empty; + return true; +#endif + } + +#if NET8_0_OR_GREATER + /// + /// Loads the viewport assembly from the connector's own directory and teaches the + /// default load context to resolve its dependency graph from there too. The + /// viewport is installed side by side rather than referenced, so it is absent from + /// the connector's dependency manifest and plain assembly binding cannot find it. + /// +#if NET9_0_OR_GREATER + [RequiresUnreferencedCode( + "The viewport and its dependencies are resolved by path, so trimming cannot see them.")] +#endif + private static Assembly LoadViewerAssembly() + { + string path = Path.Combine( + AppContext.BaseDirectory, ViewerAssemblyName + ".dll"); + if (!File.Exists(path)) + { + throw new FileNotFoundException( + "The optional viewport assembly is not installed.", path); + } + + var resolver = new AssemblyDependencyResolver(path); + AssemblyLoadContext.Default.Resolving += (context, name) => + { + string? resolved = resolver.ResolveAssemblyToPath(name); + if (resolved is null || !File.Exists(resolved)) + { + // The publish layout is flat, so fall back to a sibling probe for + // assemblies the component manifest does not place by path. + resolved = Path.Combine(AppContext.BaseDirectory, name.Name + ".dll"); + if (!File.Exists(resolved)) + { + return null; + } + } + return context.LoadFromAssemblyPath(resolved); + }; + AssemblyLoadContext.Default.ResolvingUnmanagedDll += (_, unmanaged) => + { + string? resolved = resolver.ResolveUnmanagedDllToPath(unmanaged); + return resolved is not null && File.Exists(resolved) + ? NativeLibrary.Load(resolved) + : IntPtr.Zero; + }; + return AssemblyLoadContext.Default.LoadFromAssemblyPath(path); + } +#endif + } +} diff --git a/tools/Opc.Ua.OpenUsd.Connector/app.manifest b/tools/Opc.Ua.OpenUsd.Connector/app.manifest new file mode 100644 index 0000000000..d84ffba649 --- /dev/null +++ b/tools/Opc.Ua.OpenUsd.Connector/app.manifest @@ -0,0 +1,26 @@ + + + + + + + + + + + + + PerMonitorV2 + + + true + + + + diff --git a/tools/Opc.Ua.SourceGeneration.Core/Dependency/ModelDependencyV1.cs b/tools/Opc.Ua.SourceGeneration.Core/Dependency/ModelDependencyV1.cs index b7d1aefcb2..fbbb7e97eb 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Dependency/ModelDependencyV1.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Dependency/ModelDependencyV1.cs @@ -206,6 +206,38 @@ public sealed class DependencyChild /// Output arguments (methods only). /// public IReadOnlyList OutputArguments { get; set; } = []; + + /// + /// Qualified method state identity used by the producing assembly. + /// Empty for legacy payloads and non-method children. + /// + public string MethodStateName { get; set; } = string.Empty; + + /// + /// Namespace URI for . + /// + public string MethodStateNamespace { get; set; } = string.Empty; + + /// + /// Qualified method declaration identity used on the wire. + /// Empty for legacy payloads and non-method children. + /// + public string MethodDeclarationName { get; set; } = string.Empty; + + /// + /// Namespace URI for . + /// + public string MethodDeclarationNamespace { get; set; } = string.Empty; + + /// + /// Numeric NodeId identifier for the effective method declaration. + /// + public uint MethodDeclarationNumericId { get; set; } + + /// + /// String NodeId identifier for the effective method declaration. + /// + public string? MethodDeclarationStringId { get; set; } } /// @@ -297,11 +329,23 @@ public sealed class ModelDependencyV1 /// public const byte CompressionDeflate = 1; + private const byte kFluentAccessorsEmitted = 0x01; + private const byte kFluentAccessorsKnown = 0x40; + private const byte kMethodIdentityTrailer = 0x80; + private const byte kMethodIdentityTrailerVersion = 1; + /// /// The model URI this dependency payload describes. /// public string ModelUri { get; set; } = string.Empty; + /// + /// Whether the producing assembly emitted the per-ObjectType typed + /// fluent accessor extension classes. null means the payload + /// predates this capability and cannot prove whether accessors exist. + /// + public bool? FluentAccessorsEmitted { get; set; } + /// /// Nodes in the dependency payload. /// @@ -442,6 +486,28 @@ private void WriteUncompressed(Stream destination) } } } + bool hasMethodIdentityTrailer = HasMethodIdentityTrailer(); + if (FluentAccessorsEmitted.HasValue || hasMethodIdentityTrailer) + { + byte capabilities = 0; + if (FluentAccessorsEmitted.HasValue) + { + capabilities |= kFluentAccessorsKnown; + if (FluentAccessorsEmitted.Value) + { + capabilities |= kFluentAccessorsEmitted; + } + } + if (hasMethodIdentityTrailer) + { + capabilities |= kMethodIdentityTrailer; + } + writer.Write(capabilities); + if (hasMethodIdentityTrailer) + { + WriteMethodIdentityTrailer(writer); + } + } } private void ReadUncompressed(Stream source) @@ -557,6 +623,102 @@ private void ReadUncompressed(Stream source) } Nodes.Add(node); } + if (reader.BaseStream.Position < reader.BaseStream.Length) + { + byte capabilities = reader.ReadByte(); + bool hasMethodIdentityTrailer = + (capabilities & kMethodIdentityTrailer) != 0; + if (hasMethodIdentityTrailer) + { + FluentAccessorsEmitted = + (capabilities & kFluentAccessorsKnown) != 0 + ? (capabilities & kFluentAccessorsEmitted) != 0 + : null; + } + else + { + FluentAccessorsEmitted = + (capabilities & kFluentAccessorsEmitted) != 0; + } + if (hasMethodIdentityTrailer && + reader.BaseStream.Position < reader.BaseStream.Length) + { + ReadMethodIdentityTrailer(reader); + } + } + } + + private bool HasMethodIdentityTrailer() + { + foreach (DependencyNode node in Nodes) + { + foreach (DependencyChild child in node.Children) + { + if (!string.IsNullOrEmpty(child.MethodStateName) || + !string.IsNullOrEmpty(child.MethodDeclarationName) || + child.MethodDeclarationNumericId != 0 || + !string.IsNullOrEmpty(child.MethodDeclarationStringId)) + { + return true; + } + } + } + return false; + } + + private void WriteMethodIdentityTrailer(BinaryWriter writer) + { + writer.Write(kMethodIdentityTrailerVersion); + writer.Write(Nodes.Count); + foreach (DependencyNode node in Nodes) + { + writer.Write(node.Children.Count); + foreach (DependencyChild child in node.Children) + { + WriteString(writer, child.MethodStateName); + WriteString(writer, child.MethodStateNamespace); + WriteString(writer, child.MethodDeclarationName); + WriteString(writer, child.MethodDeclarationNamespace); + writer.Write(child.MethodDeclarationNumericId); + WriteNullableString(writer, child.MethodDeclarationStringId); + } + } + } + + private void ReadMethodIdentityTrailer(BinaryReader reader) + { + byte trailerVersion = reader.ReadByte(); + if (trailerVersion != kMethodIdentityTrailerVersion) + { + return; + } + + int nodeCount = reader.ReadInt32(); + if (nodeCount != Nodes.Count) + { + throw new InvalidDataException( + "ModelDependencyV1: invalid method identity node count " + nodeCount); + } + for (int i = 0; i < nodeCount; i++) + { + DependencyNode node = Nodes[i]; + int childCount = reader.ReadInt32(); + if (childCount != node.Children.Count) + { + throw new InvalidDataException( + "ModelDependencyV1: invalid method identity child count " + childCount); + } + for (int j = 0; j < childCount; j++) + { + DependencyChild child = node.Children[j]; + child.MethodStateName = ReadString(reader); + child.MethodStateNamespace = ReadString(reader); + child.MethodDeclarationName = ReadString(reader); + child.MethodDeclarationNamespace = ReadString(reader); + child.MethodDeclarationNumericId = reader.ReadUInt32(); + child.MethodDeclarationStringId = ReadNullableString(reader); + } + } } private static void WriteString(BinaryWriter writer, string value) diff --git a/tools/Opc.Ua.SourceGeneration.Core/GeneratorOptions.cs b/tools/Opc.Ua.SourceGeneration.Core/GeneratorOptions.cs index 843d9dcb68..a4eff36f6c 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/GeneratorOptions.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/GeneratorOptions.cs @@ -93,6 +93,19 @@ public sealed class GeneratorOptions /// public bool OmitFluentApi { get; set; } + /// + /// When set to true, generation is limited to the + /// per-ObjectType typed fluent accessor extension classes emitted + /// by . The target model must + /// already be supplied by a referenced assembly under the same + /// model URI and C# prefix so the emitted accessors can reference + /// that assembly's state types. Its model dependency payload must + /// explicitly declare that the producer omitted fluent accessors; + /// missing capability metadata and producers that already emitted + /// accessors are rejected. + /// + public bool FluentAccessorsOnly { get; set; } + /// /// Optional override for the C# namespace used by classes emitted /// by the . When unset, diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators.cs index b956a20cce..affa6a7837 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators.cs @@ -50,9 +50,59 @@ public static class Generators /// Generator options /// allow subtypes /// Any additional csv files + /// Models supplied by referenced assemblies. + /// Optional node manager bindings. + /// Optional binding diagnostic callback. + /// Optional set that accumulates matched bindings. + /// Total number of generatable models across all passes. + public static void GenerateCode( + this DesignFileCollection designFiles, + IFileSystem fileSystem, + string outputDir, + ITelemetryContext telemetry, + GeneratorOptions options = null, + bool useAllowSubtypes = false, + List identifierFiles = null, + IReadOnlyDictionary referencedModels = null, + IReadOnlyList nodeManagerBindings = null, + Action reportBindingDiagnostic = null, + HashSet sharedUsedBindings = null, + int bindingModelCount = 0) + { + GenerateCode( + designFiles, + fileSystem, + outputDir, + telemetry, + options, + useAllowSubtypes, + identifierFiles, + referencedModels, + nodeManagerBindings, + reportBindingDiagnostic, + sharedUsedBindings, + bindingModelCount, + null, + null, + null); + } + + /// + /// Generate code from design files with fluent-accessor provider metadata. + /// + /// Design files to process + /// File system abstraction to use + /// Output folder or null + /// Telemetry context for logging + /// Generator options + /// allow subtypes + /// Any additional csv files /// Models supplied by referenced /// assemblies (keyed by model URI). Used to seed the assembly - /// dependency closure and may be empty. + /// dependency closure and may be empty. Targets supplied under the + /// same C# prefix are normally skipped; when + /// is enabled they are + /// instead loaded and used to emit typed fluent accessors only. /// /// Optional [NodeManager] attribute bindings discovered in /// the consuming compilation. When supplied, each binding is @@ -80,19 +130,34 @@ public static class Generators /// single-model binding fallback and ambiguity detection. When /// 0 the per-pass model count is used (single-pass callers). /// + /// + /// Optional callback invoked when a model cannot participate in + /// fluent-accessors-only generation. Arguments are model URI, + /// requested prefix, input path, and failure reason. + /// + /// + /// All model dependency declarations from referenced assemblies, + /// including payload-bearing producers and payloadless re-exports. + /// + /// + /// All referenced assemblies that declare generated fluent accessors. + /// public static void GenerateCode( this DesignFileCollection designFiles, IFileSystem fileSystem, string outputDir, ITelemetryContext telemetry, - GeneratorOptions options = null, - bool useAllowSubtypes = false, - List identifierFiles = null, - IReadOnlyDictionary referencedModels = null, - IReadOnlyList nodeManagerBindings = null, - Action reportBindingDiagnostic = null, - HashSet sharedUsedBindings = null, - int bindingModelCount = 0) + GeneratorOptions options, + bool useAllowSubtypes, + List identifierFiles, + IReadOnlyDictionary referencedModels, + IReadOnlyList nodeManagerBindings, + Action reportBindingDiagnostic, + HashSet sharedUsedBindings, + int bindingModelCount, + Action reportFluentAccessorsOnlyDiagnostic, + IReadOnlyList referencedModelProviders, + IReadOnlyList referencedAccessorProviders) { if (designFiles.Targets == null || designFiles.Targets.Count == 0) { @@ -100,6 +165,8 @@ public static void GenerateCode( } options ??= new GeneratorOptions(); referencedModels ??= ImmutableDictionary.Empty; + referencedModelProviders ??= [.. referencedModels.Values]; + referencedAccessorProviders ??= []; // Combine with embedded resources in this assembly. fileSystem = typeof(Generators).Assembly @@ -126,12 +193,13 @@ public static void GenerateCode( // provides this model under the same C# prefix, silently // skip local generation to avoid duplicate type emission. Namespace target = modelDesign.TargetNamespace; - if (target != null && + bool targetProvidedByReference = target != null && !string.IsNullOrEmpty(target.Value) && referencedModels.TryGetValue(target.Value, out ModelDependencyReference referenced) && string.Equals(referenced.Prefix, target.Prefix, - StringComparison.Ordinal)) + StringComparison.Ordinal); + if (targetProvidedByReference && !options.FluentAccessorsOnly) { continue; } @@ -144,6 +212,7 @@ public static void GenerateCode( // NodeSetToModelDesign) emit references like // `global::Opc.Ua.DI.X` instead of `global::Opc.Ua.Di.X`. OverrideDependencyPrefixes(modelDesign, referencedModels); + EnsureUniqueTargetNamespaceName(modelDesign); DesignFileOptions effectiveOptions = ApplyNodeManagerBinding( model, @@ -151,6 +220,28 @@ public static void GenerateCode( nodeManagerBindings, usedBindings, totalDesigns); + string modelPath = model.Targets.Count > 0 + ? model.Targets[0] + : string.Empty; + + if (options.FluentAccessorsOnly && + (!ValidateFluentAccessorsOnlyTarget( + target?.Value, + target?.Prefix, + modelPath, + referencedModelProviders, + referencedAccessorProviders, + reportFluentAccessorsOnlyDiagnostic) || + !ValidateFluentAccessorsOnlyOptions( + target?.Value, + target?.Prefix, + modelPath, + options, + effectiveOptions, + reportFluentAccessorsOnlyDiagnostic))) + { + continue; + } var context = new GeneratorContext { @@ -165,7 +256,11 @@ public static void GenerateCode( context, validateSchemas: false, designOptions: effectiveOptions); - if (!options.OmitEventRecords) + // In fluent-accessors-only mode the model itself is already + // supplied by a referenced assembly, which carries its event + // records too. Emitting them again here would duplicate every + // record type the reference already exports (CS0436). + if (!options.OmitEventRecords && !options.FluentAccessorsOnly) { new EventRecordGenerator(context).Emit(); } @@ -208,6 +303,7 @@ internal static void OverrideDependencyPrefixes( { return; } + string targetUri = modelDesign.TargetNamespace?.Value; foreach (Namespace ns in modelDesign.Namespaces) { @@ -239,6 +335,199 @@ internal static void OverrideDependencyPrefixes( } } + internal static void EnsureUniqueTargetNamespaceName(IModelDesign modelDesign) + { + Namespace target = modelDesign?.TargetNamespace; + if (target == null || + string.IsNullOrEmpty(target.Value) || + string.IsNullOrEmpty(target.Name) || + modelDesign.Namespaces == null) + { + return; + } + + bool duplicate = modelDesign.Namespaces.Any(ns => + ns != null && + !string.Equals(ns.Value, target.Value, StringComparison.Ordinal) && + string.Equals(ns.Name, target.Name, StringComparison.Ordinal)); + if (!duplicate) + { + return; + } + + string candidate = CreateNamespaceConstantName(target.Prefix); + if (string.IsNullOrEmpty(candidate) || + string.Equals(candidate, target.Name, StringComparison.Ordinal)) + { + candidate = target.Name + "Namespace"; + } + + HashSet usedNames = [.. modelDesign.Namespaces + .Where(ns => ns != null && + !string.Equals(ns.Value, target.Value, StringComparison.Ordinal) && + !string.IsNullOrEmpty(ns.Name)) + .Select(ns => ns.Name)]; + string uniqueName = candidate; + int suffix = 2; + while (usedNames.Contains(uniqueName)) + { + uniqueName = candidate + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture); + suffix++; + } + target.Name = uniqueName; + foreach (Namespace ns in modelDesign.Namespaces.Where(ns => + ns != null && + string.Equals(ns.Value, target.Value, StringComparison.Ordinal))) + { + ns.Name = uniqueName; + } + } + + private static string CreateNamespaceConstantName(string prefix) + { + if (string.IsNullOrEmpty(prefix)) + { + return null; + } + + string[] parts = prefix.Split(['.', '-', '_', '/', ':'], StringSplitOptions.RemoveEmptyEntries); + return string.Concat(parts.Select(part => part.ToSafeSymbolName().ToUpperCamelCase())); + } + + private static bool ValidateFluentAccessorsOnlyTarget( + string modelUri, + string prefix, + string path, + IReadOnlyList referencedModelProviders, + IReadOnlyList referencedAccessorProviders, + Action reportDiagnostic) + { + if (string.IsNullOrEmpty(modelUri)) + { + reportDiagnostic?.Invoke( + modelUri ?? string.Empty, + prefix ?? string.Empty, + path ?? string.Empty, + "no referenced assembly supplies the target model URI."); + return false; + } + + ModelFluentAccessorProviderReference accessorProvider = + referencedAccessorProviders.FirstOrDefault(provider => + provider.IsValid && + string.Equals(provider.ModelUri, modelUri, StringComparison.Ordinal) && + string.Equals(provider.Prefix, prefix, StringComparison.Ordinal)); + if (accessorProvider.IsValid) + { + reportDiagnostic?.Invoke( + modelUri, + prefix ?? string.Empty, + path ?? string.Empty, + "referenced assembly '" + accessorProvider.AssemblyName + + "' already provides generated fluent accessors."); + return false; + } + + var producers = + new List<(ModelDependencyReference Reference, + Dependency.ModelDependencyV1 Dependency)>(); + foreach (ModelDependencyReference reference in referencedModelProviders.Where( + reference => + reference.IsValid && + string.Equals(reference.ModelUri, modelUri, StringComparison.Ordinal))) + { + Dependency.ModelDependencyV1 dependency = reference.GetDependency(); + if (string.Equals(reference.Prefix, prefix, StringComparison.Ordinal) && + !string.IsNullOrEmpty(reference.Payload) && + (dependency == null || + !string.Equals( + dependency.ModelUri, + modelUri, + StringComparison.Ordinal))) + { + reportDiagnostic?.Invoke( + modelUri, + prefix ?? string.Empty, + path ?? string.Empty, + "model producer '" + reference.AssemblyName + + "' has a malformed model dependency payload."); + return false; + } + if (dependency != null && + string.Equals(dependency.ModelUri, modelUri, StringComparison.Ordinal)) + { + producers.Add((reference, dependency)); + } + } + List<(ModelDependencyReference Reference, Dependency.ModelDependencyV1 Dependency)> + matchingProducers = [.. producers.Where(entry => + string.Equals(entry.Reference.Prefix, prefix, StringComparison.Ordinal))]; + if (matchingProducers.Count == 0) + { + string reason = producers.Count > 0 + ? "model producer '" + producers[0].Reference.AssemblyName + + "' supplies prefix '" + producers[0].Reference.Prefix + "'." + : "no payload-bearing referenced model producer supplies the target model."; + reportDiagnostic?.Invoke( + modelUri, + prefix ?? string.Empty, + path ?? string.Empty, + reason); + return false; + } + + foreach ((ModelDependencyReference reference, + Dependency.ModelDependencyV1 dependency) in matchingProducers) + { + if (!dependency.FluentAccessorsEmitted.HasValue) + { + reportDiagnostic?.Invoke( + modelUri, + prefix ?? string.Empty, + path ?? string.Empty, + "model producer '" + reference.AssemblyName + + "' has unknown legacy fluent-accessor capability."); + return false; + } + if (dependency.FluentAccessorsEmitted.Value) + { + reportDiagnostic?.Invoke( + modelUri, + prefix ?? string.Empty, + path ?? string.Empty, + "model producer '" + reference.AssemblyName + + "' already contains fluent accessors."); + return false; + } + } + return true; + } + + private static bool ValidateFluentAccessorsOnlyOptions( + string modelUri, + string prefix, + string path, + GeneratorOptions options, + DesignFileOptions designOptions, + Action reportDiagnostic) + { + string reason = options.OmitFluentApi + ? "OmitFluentApi is also enabled." + : designOptions?.GenerateNodeManager == true + ? "NodeManager generation is also enabled." + : null; + if (reason == null) + { + return true; + } + reportDiagnostic?.Invoke( + modelUri ?? string.Empty, + prefix ?? string.Empty, + path ?? string.Empty, + reason); + return false; + } + /// /// Resolve the effective per-design options by overlaying any /// matching [NodeManager] attribute binding on top of the @@ -382,11 +671,61 @@ public static void ReportUnmatchedNodeManagerBindings( /// Telemetry context for logging /// Generator options /// allow subtypes + /// Models supplied by referenced assemblies. + /// Optional node manager bindings. + /// Optional binding diagnostic callback. + /// Per-URI model dependency payloads. + /// Optional set that accumulates matched bindings. + /// Total number of generatable models across all passes. + public static void GenerateCode( + this NodesetFileCollection nodesets, + IFileSystem fileSystem, + string outputDir, + ITelemetryContext telemetry, + GeneratorOptions options = null, + bool useAllowSubtypes = false, + IReadOnlyDictionary referencedModels = null, + IReadOnlyList nodeManagerBindings = null, + Action reportBindingDiagnostic = null, + IReadOnlyDictionary referencedDependencies = null, + HashSet sharedUsedBindings = null, + int bindingModelCount = 0) + { + GenerateCode( + nodesets, + fileSystem, + outputDir, + telemetry, + options, + useAllowSubtypes, + referencedModels, + nodeManagerBindings, + reportBindingDiagnostic, + referencedDependencies, + sharedUsedBindings, + bindingModelCount, + null, + null, + null); + } + + /// + /// Generate from nodesets with fluent-accessor provider metadata. + /// + /// Nodesets to process + /// File system abstraction to use + /// Output folder or null + /// Telemetry context for logging + /// Generator options + /// allow subtypes /// Models supplied by referenced /// assemblies (keyed by model URI). When a target's model URI - /// is in this map the nodeset is skipped (referenced assembly - /// already supplies the types). Transitive nodeset dependencies - /// found in the map are also satisfied without erroring. + /// is in this map under the same C# prefix the nodeset is normally + /// skipped because the referenced assembly already supplies the + /// types. When is + /// enabled the target is instead loaded and used to emit typed + /// fluent accessors only. Transitive nodeset dependencies found in + /// the map are also satisfied without erroring. /// /// Optional [NodeManager] attribute bindings discovered in /// the consuming compilation. When supplied, each binding is @@ -421,19 +760,34 @@ public static void ReportUnmatchedNodeManagerBindings( /// single-model binding fallback and ambiguity detection. When /// 0 the per-pass model count is used (single-pass callers). /// + /// + /// Optional callback invoked when a model cannot participate in + /// fluent-accessors-only generation. Arguments are model URI, + /// requested prefix, input path, and failure reason. + /// + /// + /// All model dependency declarations from referenced assemblies, + /// including payload-bearing producers and payloadless re-exports. + /// + /// + /// All referenced assemblies that declare generated fluent accessors. + /// public static void GenerateCode( this NodesetFileCollection nodesets, IFileSystem fileSystem, string outputDir, ITelemetryContext telemetry, - GeneratorOptions options = null, - bool useAllowSubtypes = false, - IReadOnlyDictionary referencedModels = null, - IReadOnlyList nodeManagerBindings = null, - Action reportBindingDiagnostic = null, - IReadOnlyDictionary referencedDependencies = null, - HashSet sharedUsedBindings = null, - int bindingModelCount = 0) + GeneratorOptions options, + bool useAllowSubtypes, + IReadOnlyDictionary referencedModels, + IReadOnlyList nodeManagerBindings, + Action reportBindingDiagnostic, + IReadOnlyDictionary referencedDependencies, + HashSet sharedUsedBindings, + int bindingModelCount, + Action reportFluentAccessorsOnlyDiagnostic, + IReadOnlyList referencedModelProviders, + IReadOnlyList referencedAccessorProviders) { if (nodesets.Files.Count == 0) { @@ -441,6 +795,8 @@ public static void GenerateCode( } options ??= new GeneratorOptions(); referencedModels ??= ImmutableDictionary.Empty; + referencedModelProviders ??= [.. referencedModels.Values]; + referencedAccessorProviders ??= []; // Combine with embedded resources in this assembly. fileSystem = typeof(Generators).Assembly @@ -469,10 +825,11 @@ public static void GenerateCode( // Override resolution: if a referenced assembly already // provides this model under the same C# prefix, silently // skip local generation to avoid duplicate type emission. - if (referencedModels.TryGetValue(modelUri, + bool targetProvidedByReference = referencedModels.TryGetValue(modelUri, out ModelDependencyReference referenced) && string.Equals(referenced.Prefix, nodeset.Info.Prefix, - StringComparison.Ordinal)) + StringComparison.Ordinal); + if (targetProvidedByReference && !options.FluentAccessorsOnly) { continue; } @@ -481,12 +838,26 @@ public static void GenerateCode( { Targets = designFilesForModel }; + IReadOnlyDictionary + validationDependencies = referencedDependencies; + if (options.FluentAccessorsOnly && + referencedDependencies != null && + referencedDependencies.ContainsKey(modelUri)) + { + Dictionary dependenciesWithoutTarget = + referencedDependencies.ToDictionary( + entry => entry.Key, + entry => entry.Value, + StringComparer.Ordinal); + dependenciesWithoutTarget.Remove(modelUri); + validationDependencies = dependenciesWithoutTarget; + } IModelDesign modelDesign = fileSystem.OpenModelDesign( model, options.Exclusions, telemetry, useAllowSubtypes, - referencedDependencies); + validationDependencies); // Cross-namespace prefix override: when a referenced // assembly publishes a model under a specific C# prefix, @@ -494,6 +865,7 @@ public static void GenerateCode( // generated type references resolve against the referenced // assembly's actual prefix (not the auto-generated one). OverrideDependencyPrefixes(modelDesign, referencedModels); + EnsureUniqueTargetNamespaceName(modelDesign); DesignFileOptions effectiveOptions = ApplyNodeManagerBinding( model, @@ -502,6 +874,25 @@ public static void GenerateCode( usedBindings, totalDesigns); + if (options.FluentAccessorsOnly && + (!ValidateFluentAccessorsOnlyTarget( + modelUri, + nodeset.Info.Prefix, + nodeset.FileName, + referencedModelProviders, + referencedAccessorProviders, + reportFluentAccessorsOnlyDiagnostic) || + !ValidateFluentAccessorsOnlyOptions( + modelUri, + nodeset.Info.Prefix, + nodeset.FileName, + options, + effectiveOptions, + reportFluentAccessorsOnlyDiagnostic))) + { + continue; + } + var context = new GeneratorContext { FileSystem = fileSystem, @@ -515,7 +906,11 @@ public static void GenerateCode( context, validateSchemas: false, designOptions: effectiveOptions); - if (!options.OmitEventRecords) + // In fluent-accessors-only mode the model itself is already + // supplied by a referenced assembly, which carries its event + // records too. Emitting them again here would duplicate every + // record type the reference already exports (CS0436). + if (!options.OmitEventRecords && !options.FluentAccessorsOnly) { new EventRecordGenerator(context).Emit(); } @@ -660,6 +1055,16 @@ private static void Generate( bool validateSchemas = false, DesignFileOptions designOptions = null) { + if (context.Options?.FluentAccessorsOnly == true) + { + new FluentBuilderGenerator(context) + { + GenerateManagerWrappers = false, + EmitFluentAccessors = true + }.Emit(); + return; + } + // Generate schemas var xmlSchemaGenerator = new XmlSchemaGenerator(context) { @@ -731,7 +1136,9 @@ private static void Generate( } if (context.Options?.EmitDependencyMetadata != false) { - var modelDependencyGenerator = new ModelDependencyGenerator(context); + var modelDependencyGenerator = new ModelDependencyGenerator( + context, + emitTypedAccessors); modelDependencyGenerator.Emit(); } } diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeGenerator.cs index e5a5a0c62b..30f11df6a0 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeGenerator.cs @@ -531,10 +531,12 @@ private bool WriteTemplate_ListOfTypes(IWriteContext context) Tokens.NamespacePrefix, m_context.ModelDesign.Namespaces.GetNamespacePrefix( dataType.SymbolicId.Namespace)); + string xmlNamespaceUri = + m_context.ModelDesign.Namespaces.GetConstantForXmlNamespace( + dataType.SymbolicId.Namespace); context.Template.AddReplacement( Tokens.XmlNamespaceUri, - m_context.ModelDesign.Namespaces.GetConstantForXmlNamespace( - dataType.SymbolicId.Namespace)); + xmlNamespaceUri); context.Template.AddBrowseNameReplacement( Tokens.BrowseName, @@ -606,7 +608,28 @@ private bool WriteTemplate_ListOfTypes(IWriteContext context) context.Template.AddReplacement( Tokens.EncodingMaskModifier, - hasAncestorWithOptionalFields ? "new " : string.Empty); + hasAncestorWithOptionalFields ? "override " : "virtual "); + context.Template.AddReplacement( + Tokens.EncodingMaskFieldNamesModifier, + hasAncestorWithOptionalFields ? "override " : "virtual "); + context.Template.AddReplacement( + Tokens.EncodingMaskEncode, + hasAncestorWithOptionalFields + ? string.Empty + : $$""" + encoder.PushNamespace({{xmlNamespaceUri}}); + encoder.WriteEncodingMask((uint)EncodingMask); + encoder.PopNamespace(); + """); + context.Template.AddReplacement( + Tokens.EncodingMaskDecode, + hasAncestorWithOptionalFields + ? string.Empty + : $$""" + decoder.PushNamespace({{xmlNamespaceUri}}); + EncodingMask = decoder.ReadEncodingMask(EncodingMaskFieldNames); + decoder.PopNamespace(); + """); // TODO: context.Template.AddReplacement( // TODO: Tokens.IsAbstract, diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeTemplates.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeTemplates.cs index 545c228186..92d27df01d 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeTemplates.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/DataTypeTemplates.cs @@ -704,6 +704,12 @@ private void Initialize() [global::System.Runtime.Serialization.DataMember(Name = "EncodingMask", IsRequired = true, Order = 0)] public {{Tokens.EncodingMaskModifier}}uint EncodingMask { get; set; } + /// + /// The complete list of optional field names for this structure. + /// + protected {{Tokens.EncodingMaskFieldNamesModifier}}global::System.Collections.Generic.IList EncodingMaskFieldNames + => m_FieldNames; + /// public override global::Opc.Ua.ExpandedNodeId TypeId => DataTypeIds.{{Tokens.BrowseName}}; @@ -716,10 +722,7 @@ private void Initialize() /// public override void Encode(global::Opc.Ua.IEncoder encoder) { - encoder.PushNamespace({{Tokens.XmlNamespaceUri}}); - encoder.WriteEncodingMask((uint)EncodingMask); - encoder.PopNamespace(); - + {{Tokens.EncodingMaskEncode}} base.Encode(encoder); encoder.PushNamespace({{Tokens.XmlNamespaceUri}}); @@ -730,10 +733,7 @@ public override void Encode(global::Opc.Ua.IEncoder encoder) /// public override void Decode(global::Opc.Ua.IDecoder decoder) { - decoder.PushNamespace({{Tokens.XmlNamespaceUri}}); - EncodingMask = decoder.ReadEncodingMask(m_FieldNames); - decoder.PopNamespace(); - + {{Tokens.EncodingMaskDecode}} base.Decode(decoder); decoder.PushNamespace({{Tokens.XmlNamespaceUri}}); @@ -886,6 +886,12 @@ private void Initialize() [global::System.Runtime.Serialization.DataMember(Name = "EncodingMask", IsRequired = true, Order = 0)] public virtual uint EncodingMask { get; set; } + /// + /// The complete list of optional field names for this structure. + /// + protected virtual global::System.Collections.Generic.IList EncodingMaskFieldNames + => m_FieldNames; + {{Tokens.ListOfProperties}} /// @@ -910,7 +916,7 @@ public virtual void Encode(global::Opc.Ua.IEncoder encoder) public virtual void Decode(global::Opc.Ua.IDecoder decoder) { decoder.PushNamespace({{Tokens.XmlNamespaceUri}}); - EncodingMask = decoder.ReadEncodingMask(m_FieldNames); + EncodingMask = decoder.ReadEncodingMask(EncodingMaskFieldNames); {{Tokens.ListOfDecodedFields}} decoder.PopNamespace(); } diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs index c8169d6d74..09ba25146e 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderGenerator.cs @@ -170,6 +170,9 @@ public IEnumerable Emit() using var templateWriter = new TemplateWriter(writer); var template = new Template(templateWriter, FluentBuilderTemplates.File); + template.AddReplacement( + Tokens.ModelUri, + EscapeStringLiteral(m_context.ModelDesign.TargetNamespace.Value)); template.AddReplacement(Tokens.NamespacePrefix, outputNamespace); // Render the typed manager interface, the typed manager @@ -469,14 +472,17 @@ private void RegisterMethodWrapper( string leafName = ResolveLeafName(root, relativePath, method); string parentKey = ResolveParentKey(root, relativePath, leafName); string className = ComposeWrapperClassName(leafName, suffix: "MethodBuilder"); + MethodDesign effectiveMethod = method.IsOverridden() + ? (MethodDesign)method.GetMergedInstance() + : method; m_methodWrappers[key] = new MethodWrapper { Key = key, ClassName = className, LeafName = leafName, ParentKey = parentKey, - Inputs = MethodDesignArgumentResolver.ResolveMethodInputs(method), - Outputs = MethodDesignArgumentResolver.ResolveMethodOutputs(method) + Inputs = MethodDesignArgumentResolver.ResolveMethodInputs(effectiveMethod), + Outputs = MethodDesignArgumentResolver.ResolveMethodOutputs(effectiveMethod) }; } @@ -1069,9 +1075,14 @@ private string ResolveObjectTypeStateClr(ObjectTypeDesign type) { return null; } - string stateName = typeName.EndsWith("Type", StringComparison.Ordinal) - ? typeName[..^"Type".Length] + "State" - : typeName + "State"; + if (!string.IsNullOrEmpty(type.ClassName)) + { + return type.GetClassName(m_context.ModelDesign.Namespaces) + "State"; + } + string className = typeName.EndsWith("Type", StringComparison.Ordinal) + ? typeName[..^"Type".Length] + : typeName; + string stateName = className + "State"; string nsUri = type.SymbolicName?.Namespace; string prefix = ResolveCSharpNamespaceForUri(nsUri); return string.IsNullOrEmpty(prefix) @@ -1096,6 +1107,10 @@ private string ResolveChildStateClr(InstanceDesign child) { return ResolveStateClrType(child); } + if (child.TypeDefinitionNode is ObjectTypeDesign objectType) + { + return ResolveObjectTypeStateClr(objectType); + } string stateName = typeDef.Name.EndsWith("Type", StringComparison.Ordinal) ? typeDef.Name[..^"Type".Length] + "State" : typeDef.Name + "State"; diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs index 611366e13d..aa0ef203e9 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/FluentBuilderTemplates.cs @@ -56,6 +56,10 @@ internal static class FluentBuilderTemplates #nullable enable + [assembly: global::Opc.Ua.ModelFluentAccessorProviderAttribute( + "{{Tokens.ModelUri}}", + "{{Tokens.NamespacePrefix}}")] + namespace {{Tokens.NamespacePrefix}} { {{Tokens.ListOfTypes}} diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/MethodDesignArgumentResolver.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/MethodDesignArgumentResolver.cs index a393fe680f..6760e0c043 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/MethodDesignArgumentResolver.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/MethodDesignArgumentResolver.cs @@ -28,6 +28,8 @@ * ======================================================================*/ using System; +using System.Collections.Generic; +using System.Xml; using Opc.Ua.Schema.Model; namespace Opc.Ua.SourceGeneration @@ -47,10 +49,7 @@ public static Parameter[] ResolveMethodInputs(MethodDesign method) throw new ArgumentNullException(nameof(method)); } - return ResolveArguments( - method.InputArguments, - method.MethodDeclarationNode?.InputArguments, - method.MethodType?.InputArguments); + return ResolveMethodDefinition(method).InputArguments ?? []; } /// @@ -63,33 +62,237 @@ public static Parameter[] ResolveMethodOutputs(MethodDesign method) throw new ArgumentNullException(nameof(method)); } - return ResolveArguments( - method.OutputArguments, - method.MethodDeclarationNode?.OutputArguments, - method.MethodType?.OutputArguments); + return ResolveMethodDefinition(method).OutputArguments ?? []; } - private static Parameter[] ResolveArguments( - Parameter[] methodArguments, - Parameter[] declarationArguments, - Parameter[] methodTypeArguments) + /// + /// Returns whether a method or one of its declaration/type links defines arguments. + /// + public static bool HasMethodArguments(MethodDesign method) + { + if (method == null) + { + throw new ArgumentNullException(nameof(method)); + } + + MethodDesign definition = ResolveMethodDefinition(method); + return method.HasArguments || + definition.HasArguments || + HasDeclaredArguments(definition); + } + + /// + /// Returns whether the method directly declares any arguments. + /// + public static bool HasDeclaredArguments(MethodDesign method) + { + if (method == null) + { + throw new ArgumentNullException(nameof(method)); + } + + return method.InputArguments is { Length: > 0 } || + method.OutputArguments is { Length: > 0 }; + } + + /// + /// Resolves the method design that owns the effective method state and + /// argument metadata. + /// + public static MethodDesign ResolveMethodDefinition(MethodDesign method) + { + if (method == null) + { + throw new ArgumentNullException(nameof(method)); + } + + return ResolveMethodDefinition(method, []); + } + + /// + /// Resolves the qualified identity used to name the generated method + /// state class. + /// + public static XmlQualifiedName ResolveMethodStateIdentity(MethodDesign method) + { + if (method == null) + { + throw new ArgumentNullException(nameof(method)); + } + + if (method.TypeDefinition != null && method.MethodType == null) + { + return method.TypeDefinition; + } + + MethodDesign definition = ResolveMethodDefinition(method); + return definition.TypeDefinition ?? + definition.SymbolicName ?? + definition.SymbolicId ?? + method.SymbolicName ?? + method.SymbolicId; + } + + /// + /// Returns whether two methods have identical effective signatures. + /// + public static bool HaveSameMethodSignature( + MethodDesign first, + MethodDesign second) + { + if (first == null) + { + throw new ArgumentNullException(nameof(first)); + } + if (second == null) + { + throw new ArgumentNullException(nameof(second)); + } + + return HaveSameSignature( + ResolveMethodInputs(first), + ResolveMethodOutputs(first), + ResolveMethodInputs(second), + ResolveMethodOutputs(second)); + } + + /// + /// Returns whether two methods directly declare identical signatures. + /// + public static bool HaveSameDeclaredSignature( + MethodDesign first, + MethodDesign second) + { + if (first == null) + { + throw new ArgumentNullException(nameof(first)); + } + if (second == null) + { + throw new ArgumentNullException(nameof(second)); + } + + return HaveSameSignature( + first.InputArguments ?? [], + first.OutputArguments ?? [], + second.InputArguments ?? [], + second.OutputArguments ?? []); + } + + private static MethodDesign ResolveMethodDefinition( + MethodDesign method, + List visited) + { + if (ContainsReference(visited, method)) + { + return method; + } + visited.Add(method); + + bool hasDeclaredArguments = HasDeclaredArguments(method); + if (method.MethodType != null && + (!hasDeclaredArguments || + HaveSameSignature( + method.InputArguments ?? [], + method.OutputArguments ?? [], + ResolveMethodInputs(method.MethodType), + ResolveMethodOutputs(method.MethodType)))) + { + return ResolveMethodDefinition(method.MethodType, visited); + } + + if (method.MethodDeclarationNode != null && + !ReferenceEquals(method.MethodDeclarationNode, method) && + (!hasDeclaredArguments || + HaveSameSignature( + method.InputArguments ?? [], + method.OutputArguments ?? [], + ResolveMethodInputs(method.MethodDeclarationNode), + ResolveMethodOutputs(method.MethodDeclarationNode)))) + { + return ResolveMethodDefinition(method.MethodDeclarationNode, visited); + } + + return method; + } + + private static bool HaveSameSignature( + Parameter[] firstInputs, + Parameter[] firstOutputs, + Parameter[] secondInputs, + Parameter[] secondOutputs) + { + return HaveSameParameters(firstInputs, secondInputs) && + HaveSameParameters(firstOutputs, secondOutputs); + } + + private static bool HaveSameParameters( + Parameter[] first, + Parameter[] second) { - if (methodArguments is { Length: > 0 }) + if (first.Length != second.Length) { - return methodArguments; + return false; } - if (declarationArguments is { Length: > 0 }) + for (int ii = 0; ii < first.Length; ii++) { - return declarationArguments; + Parameter left = first[ii]; + Parameter right = second[ii]; + if (left == null || right == null) + { + if (!ReferenceEquals(left, right)) + { + return false; + } + continue; + } + + if (!string.Equals(left.Name, right.Name, StringComparison.Ordinal) || + !XmlQualifiedNameEquals(left.DataType, right.DataType) || + left.ValueRank != right.ValueRank || + !string.Equals( + left.ArrayDimensions, + right.ArrayDimensions, + StringComparison.Ordinal) || + left.AllowSubTypes != right.AllowSubTypes || + left.IsOptional != right.IsOptional) + { + return false; + } } - if (methodTypeArguments is { Length: > 0 }) + return true; + } + + private static bool XmlQualifiedNameEquals( + XmlQualifiedName first, + XmlQualifiedName second) + { + if (ReferenceEquals(first, second)) { - return methodTypeArguments; + return true; } + if (first == null || second == null) + { + return false; + } + return string.Equals(first.Name, second.Name, StringComparison.Ordinal) && + string.Equals(first.Namespace, second.Namespace, StringComparison.Ordinal); + } - return []; + private static bool ContainsReference( + List methods, + MethodDesign candidate) + { + foreach (MethodDesign method in methods) + { + if (ReferenceEquals(method, candidate)) + { + return true; + } + } + return false; } } } diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/ModelDependencyGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/ModelDependencyGenerator.cs index 7b0bf9109a..fefee3b99e 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/ModelDependencyGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/ModelDependencyGenerator.cs @@ -31,6 +31,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Xml; using Opc.Ua.Schema.Model; using Opc.Ua.SourceGeneration.Dependency; @@ -47,9 +48,12 @@ namespace Opc.Ua.SourceGeneration /// internal sealed class ModelDependencyGenerator : IGenerator { - public ModelDependencyGenerator(IGeneratorContext context) + public ModelDependencyGenerator( + IGeneratorContext context, + bool? fluentAccessorsEmitted = null) { m_context = context ?? throw new ArgumentNullException(nameof(context)); + m_fluentAccessorsEmitted = fluentAccessorsEmitted; } /// @@ -163,7 +167,7 @@ private string BuildSelfPayload(Namespace target) return null; } ModelDependencyV1 payload = BuildPayload(target); - if (payload.Nodes.Count == 0) + if (payload.Nodes.Count == 0 && !payload.FluentAccessorsEmitted.HasValue) { return null; } @@ -172,7 +176,11 @@ private string BuildSelfPayload(Namespace target) private ModelDependencyV1 BuildPayload(Namespace target) { - var payload = new ModelDependencyV1 { ModelUri = target.Value }; + var payload = new ModelDependencyV1 + { + ModelUri = target.Value, + FluentAccessorsEmitted = m_fluentAccessorsEmitted + }; string targetUri = target.Value; foreach (NodeDesign node in m_context.ModelDesign.Nodes ?? []) { @@ -282,8 +290,66 @@ private ModelDependencyV1 BuildPayload(Namespace target) } else if (child is MethodDesign method) { - entryChild.InputArguments = DependencyMethodArgs(method.InputArguments); - entryChild.OutputArguments = DependencyMethodArgs(method.OutputArguments); + var effectiveMethod = + (MethodDesign)method.GetMergedInstance(); + entryChild.TypeDefinitionName = + effectiveMethod.TypeDefinition?.Name ?? string.Empty; + entryChild.TypeDefinitionNamespace = + effectiveMethod.TypeDefinition?.Namespace ?? string.Empty; + entryChild.InputArguments = DependencyMethodArgs( + MethodDesignArgumentResolver.ResolveMethodInputs( + effectiveMethod)); + entryChild.OutputArguments = DependencyMethodArgs( + MethodDesignArgumentResolver.ResolveMethodOutputs( + effectiveMethod)); + if (MethodDesignArgumentResolver.HasMethodArguments( + effectiveMethod)) + { + XmlQualifiedName methodStateIdentity = + MethodDesignArgumentResolver.ResolveMethodStateIdentity( + effectiveMethod); + if (methodStateIdentity != null) + { + entryChild.MethodStateName = + methodStateIdentity.Name ?? string.Empty; + entryChild.MethodStateNamespace = + methodStateIdentity.Namespace ?? string.Empty; + } + + MethodDesign declaration = + effectiveMethod.MethodDeclarationNode ?? + (MethodDesignArgumentResolver.HasDeclaredArguments( + effectiveMethod) + ? effectiveMethod + : null); + if (declaration != null) + { + XmlQualifiedName declarationIdentity = + declaration.SymbolicId ?? declaration.SymbolicName; + if (declarationIdentity != null) + { + entryChild.MethodDeclarationName = + declarationIdentity.Name ?? string.Empty; + entryChild.MethodDeclarationNamespace = + declarationIdentity.Namespace ?? string.Empty; + } + entryChild.MethodDeclarationNumericId = + declaration.NumericIdSpecified + ? declaration.NumericId + : declarationIdentity == + effectiveMethod.SymbolicId && + effectiveMethod.NumericIdSpecified + ? effectiveMethod.NumericId + : 0; + entryChild.MethodDeclarationStringId = + string.IsNullOrEmpty(declaration.StringId) + ? declarationIdentity == + effectiveMethod.SymbolicId + ? effectiveMethod.StringId + : null + : declaration.StringId; + } + } } children.Add(entryChild); } @@ -372,5 +438,6 @@ private readonly record struct Entry( string Payload); private readonly IGeneratorContext m_context; + private readonly bool? m_fluentAccessorsEmitted; } } diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs index 1f3c42ecce..15e4b848a4 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs @@ -182,7 +182,8 @@ private TemplateString LoadTemplate_ListOfNodeStateActivators(ILoadContext conte case ObjectTypeDesign: case VariableTypeDesign: case MethodDesign method - when method.HasArguments && method.IsMethodTypeDesign(): + when MethodDesignArgumentResolver.HasMethodArguments(method) && + method.IsMethodTypeDesign(): return context.TemplateString; default: return null; @@ -236,7 +237,8 @@ private TemplateString LoadTemplate_ListOfNodeStateClasses(ILoadContext context) => NodeStateTemplates.ObjectType_Class, VariableTypeDesign => NodeStateTemplates.VariableType_Class, - MethodDesign method when method.HasArguments + MethodDesign method when + MethodDesignArgumentResolver.HasMethodArguments(method) => NodeStateTemplates.MethodType_Class, _ => null }; @@ -599,7 +601,14 @@ private TemplateString LoadTemplate_ListOfFields(ILoadContext context) return null; } - if (node.IsNotExplicitlyDefined || instance.IsOverridden()) + bool isMethodOverrideWithDifferentClass = + instance is MethodDesign && + instance.IsOverridden() && + !instance.IsOverriddenWithSameClass( + m_context.ModelDesign.TargetNamespace.Value, + m_context.ModelDesign.Namespaces); + if (node.IsNotExplicitlyDefined || + (instance.IsOverridden() && !isMethodOverrideWithDifferentClass)) { return null; } @@ -631,6 +640,10 @@ or ModellingRule.MandatoryPlaceholder return null; } + if (isMethodOverrideWithDifferentClass) + { + instance = instance.GetMergedInstance(); + } context.Out.WriteLine( "private {0}? {1};", instance.GetNodeStateClassName( @@ -1026,6 +1039,10 @@ or ModellingRule.MandatoryPlaceholder { return null; } + if (instance is MethodDesign) + { + return NodeStateTemplates.Property; + } return NodeStateTemplates.PropertyOverride; } @@ -1247,6 +1264,12 @@ private TemplateString LoadTemplate_ListOfNodeStateInitializers(ILoadContext con // No registering of children return null; } + if (node.Design is MethodDesign method) + { + return MethodDesignArgumentResolver.HasMethodArguments(method) + ? NodeStateTemplates.AddMethod + : null; + } return NodeStateTemplates.Add; } @@ -1641,7 +1664,7 @@ private TemplateString LoadTemplate_ReplaceChild(ILoadContext context) forInstanceVariableValue); return null; case InstanceDesign parentInstance: - if (HasChildDefined(parentInstance.TypeDefinitionNode, instance.SymbolicName.Name) || + if (HasFixedChildSlot(parentInstance.TypeDefinitionNode, instance.SymbolicName.Name) || IsBuiltInProperty(node)) { // Children whose SymbolicName ends in "_Placeholder" @@ -2655,8 +2678,12 @@ private void CollectNodesToGenerate() } GetChildren(entry, m_nodes, false); + bool isMethodWithArguments = + node is MethodDesign method && + MethodDesignArgumentResolver.HasMethodArguments(method); if ((node is not ObjectTypeDesign and not VariableTypeDesign) && - !node.IsMethodTypeDesign()) + !node.IsMethodTypeDesign() && + !isMethodWithArguments) { continue; } @@ -3083,7 +3110,7 @@ private static bool IsBuiltInProperty(NodeToGenerate node) return false; } - private static bool HasChildDefined(TypeDesign typeDefinitionNode, string symbolicName) + internal static bool HasFixedChildSlot(TypeDesign typeDefinitionNode, string symbolicName) { if (typeDefinitionNode == null) { @@ -3093,13 +3120,14 @@ private static bool HasChildDefined(TypeDesign typeDefinitionNode, string symbol { foreach (InstanceDesign child in typeDefinitionNode.Children.Items) { - if (child.SymbolicName.Name == symbolicName) + if (child.SymbolicName.Name == symbolicName && + child.ModellingRule is ModellingRule.Mandatory or ModellingRule.Optional) { return true; } } } - return HasChildDefined(typeDefinitionNode.BaseTypeNode, symbolicName); + return HasFixedChildSlot(typeDefinitionNode.BaseTypeNode, symbolicName); } /// diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs index dc5e219a0e..a59e03372d 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs @@ -1414,6 +1414,20 @@ public static readonly {{Tokens.StateClassName}}Activator Instance } """); + /// + /// Template for a single method state creation call. + /// + public static readonly TemplateString AddMethod = TemplateString.Parse( + $$""" + // Add {{Tokens.SymbolicName}} predefined node + { + global::Opc.Ua.NodeState state = + context.CreateInstanceOf{{Tokens.SymbolicId}}(); + state.CreateAsPredefinedNode(context); + nodes.Add(state); + } + """); + /// /// Template for BaseObjectTypeState creation /// diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/ObjectTypeProxyGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/ObjectTypeProxyGenerator.cs index 0ad16f6a2e..15b8251cfd 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/ObjectTypeProxyGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/ObjectTypeProxyGenerator.cs @@ -524,8 +524,13 @@ private TemplateString LoadTemplate_Method(ILoadContext context) Namespace[] namespaces = m_context.ModelDesign.Namespaces; string methodName = method.SymbolicName.Name; - Parameter[] inputs = MethodDesignArgumentResolver.ResolveMethodInputs(method); - Parameter[] outputs = MethodDesignArgumentResolver.ResolveMethodOutputs(method); + MethodDesign effectiveMethod = method.IsOverridden() + ? (MethodDesign)method.GetMergedInstance() + : method; + Parameter[] inputs = + MethodDesignArgumentResolver.ResolveMethodInputs(effectiveMethod); + Parameter[] outputs = + MethodDesignArgumentResolver.ResolveMethodOutputs(effectiveMethod); string methodIdConstant = CoreUtils.Format( "global::{0}.MethodIds.{1}", diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/StateMachineIdsGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/StateMachineIdsGenerator.cs index accfd0a1df..cca68adf68 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/StateMachineIdsGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/StateMachineIdsGenerator.cs @@ -366,17 +366,13 @@ private static FsmEntry BuildStateEntry( } foreach (InstanceDesign grandchild in grandchildren) { - if (grandchild is not PropertyDesign property || + if (grandchild is not VariableDesign property || !string.Equals(property.SymbolicName?.Name, propertyName, StringComparison.Ordinal)) { continue; } - System.Xml.XmlElement element = property.DefaultValue; - if (element != null && - !string.IsNullOrWhiteSpace(element.InnerText) && - uint.TryParse(element.InnerText.Trim(), NumberStyles.Integer, - CultureInfo.InvariantCulture, out uint value)) + if (TryReadNumber(property, out uint value)) { return value; } @@ -384,6 +380,31 @@ private static FsmEntry BuildStateEntry( return null; } + private static bool TryReadNumber(VariableDesign property, out uint value) + { + if (property.DecodedValue != null && + uint.TryParse( + Convert.ToString(property.DecodedValue, CultureInfo.InvariantCulture), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out value)) + { + return true; + } + + System.Xml.XmlElement element = property.DefaultValue; + if (element != null && + !string.IsNullOrWhiteSpace(element.InnerText) && + uint.TryParse(element.InnerText.Trim(), NumberStyles.Integer, + CultureInfo.InvariantCulture, out value)) + { + return true; + } + + value = 0; + return false; + } + private sealed class FsmTypeInfo { public FsmTypeInfo(string typeName) diff --git a/tools/Opc.Ua.SourceGeneration.Core/ModelDependencyReference.cs b/tools/Opc.Ua.SourceGeneration.Core/ModelDependencyReference.cs index 385cb6ea68..0cac639e9f 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/ModelDependencyReference.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/ModelDependencyReference.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System; +using System.IO; using System.Runtime.CompilerServices; using Opc.Ua.SourceGeneration.Dependency; @@ -118,15 +119,37 @@ public ModelDependencyV1 GetDependency() { return null; } - return s_decoded.GetValue(Payload, DecodePayload); + return s_decoded.GetValue(Payload, DecodePayload).Value; } - private static ModelDependencyV1 DecodePayload(string payload) + private static DecodedDependency DecodePayload(string payload) { - return ModelDependencyV1.FromBase64Payload(payload); + try + { + return new DecodedDependency(ModelDependencyV1.FromBase64Payload(payload)); + } + catch (Exception ex) when ( + ex is InvalidDataException || + ex is IOException || + ex is ArgumentException || + ex is FormatException || + ex is OverflowException) + { + return new DecodedDependency(null); + } + } + + private sealed class DecodedDependency + { + public DecodedDependency(ModelDependencyV1 value) + { + Value = value; + } + + public ModelDependencyV1 Value { get; } } - private static readonly ConditionalWeakTable s_decoded + private static readonly ConditionalWeakTable s_decoded = new(); /// diff --git a/tools/Opc.Ua.SourceGeneration.Core/ModelFluentAccessorProviderReference.cs b/tools/Opc.Ua.SourceGeneration.Core/ModelFluentAccessorProviderReference.cs new file mode 100644 index 0000000000..7ae9153b41 --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration.Core/ModelFluentAccessorProviderReference.cs @@ -0,0 +1,75 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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; + +namespace Opc.Ua.SourceGeneration +{ + /// + /// Information read from a referenced assembly's + /// . + /// + public readonly record struct ModelFluentAccessorProviderReference + { + /// + /// Creates a provider reference. + /// + public ModelFluentAccessorProviderReference( + string assemblyName, + string modelUri, + string prefix) + { + AssemblyName = assemblyName ?? string.Empty; + ModelUri = modelUri ?? string.Empty; + Prefix = prefix ?? string.Empty; + } + + /// + /// The assembly containing the generated accessors. + /// + public string AssemblyName { get; } + + /// + /// The OPC UA model URI. + /// + public string ModelUri { get; } + + /// + /// The C# namespace containing the generated accessors. + /// + public string Prefix { get; } + + /// + /// Returns true when the provider has a model URI and C# prefix. + /// + public bool IsValid => + !string.IsNullOrEmpty(ModelUri) && + !string.IsNullOrEmpty(Prefix); + } +} diff --git a/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignExtensions.cs b/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignExtensions.cs index 9d792114f8..5a1d321497 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignExtensions.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignExtensions.cs @@ -122,9 +122,12 @@ public static string GetNodeStateClassName( if (instance is MethodDesign method) { string className; - if (method.TypeDefinition != null) + XmlQualifiedName methodStateIdentity = + MethodDesignArgumentResolver.ResolveMethodStateIdentity(method); + if (methodStateIdentity != null) { - className = method.TypeDefinition.AsFullyQualifiedTypeSymbol(namespaces); + className = + methodStateIdentity.AsFullyQualifiedTypeSymbol(namespaces); if (className.EndsWith("MethodType", StringComparison.Ordinal)) { @@ -138,14 +141,9 @@ public static string GetNodeStateClassName( else { className = method.SymbolicName.AsFullyQualifiedTypeSymbol(namespaces); - - if (className.EndsWith("MethodType", StringComparison.Ordinal)) - { - className = className[..^"MethodType".Length]; - } } - if (method.HasArguments) + if (MethodDesignArgumentResolver.HasMethodArguments(method)) { string typedClassName = CoreUtils.Format( "{0}{1}MethodState", diff --git a/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignValidator.cs b/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignValidator.cs index 45e7363d5e..1f069d6427 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignValidator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignValidator.cs @@ -3531,6 +3531,8 @@ private void ImportInstance(InstanceDesign instance) } method.OutputArguments = parameters; + method.HasArguments = + MethodDesignArgumentResolver.HasMethodArguments(method); } } @@ -4229,16 +4231,12 @@ private void ValidateInstance(InstanceDesign instance) { if (instance.TypeDefinition != null) { - method.MethodType = this.FindNode( + method.MethodType ??= this.FindNode( instance.TypeDefinition, instance.SymbolicId.Name, "TypeDefinition"); method.Description = method.MethodType.Description; - method.InputArguments = method.MethodType.InputArguments; - method.OutputArguments = method.MethodType.OutputArguments; - method.HasArguments = (method.InputArguments != null && method.InputArguments.Length > 0) || - (method.OutputArguments != null && method.OutputArguments.Length > 0); //if (!method.ModellingRuleSpecified || method.ModellingRule == ModellingRule.None) //{ @@ -4268,8 +4266,14 @@ private void ValidateInstance(InstanceDesign instance) //} } + MethodDesign methodDefinition = + MethodDesignArgumentResolver.ResolveMethodDefinition(method); + method.InputArguments = methodDefinition.InputArguments ?? []; + method.OutputArguments = methodDefinition.OutputArguments ?? []; ValidateParameters(method, method.InputArguments); ValidateParameters(method, method.OutputArguments); + method.HasArguments = + MethodDesignArgumentResolver.HasMethodArguments(method); if (method.Parent != null) { @@ -4698,9 +4702,23 @@ private NodeDesign CreateMergedInstance( mergedInstance = instance.Copy(); if (instance is MethodDesign method) - { - ((MethodDesign)mergedInstance).MethodDeclarationNode = method; + var mergedMethod = (MethodDesign)mergedInstance; + // The merged copy overrides the instance declaration it was + // copied from, so that declaration is what a caller may pass + // to Call as the MethodId. Pointing the link at the copy + // itself would make MethodDeclarationId self referential and + // the supertype's MethodId would no longer resolve. + mergedMethod.MethodDeclarationNode ??= + method.MethodDeclarationNode ?? method; + MethodDesign methodDefinition = + MethodDesignArgumentResolver.ResolveMethodDefinition(mergedMethod); + mergedMethod.InputArguments = + methodDefinition.InputArguments ?? []; + mergedMethod.OutputArguments = + methodDefinition.OutputArguments ?? []; + mergedMethod.HasArguments = + MethodDesignArgumentResolver.HasMethodArguments(mergedMethod); } } else @@ -5083,6 +5101,41 @@ private static void UpdateMergedInstance( MethodDesign mergedMethod, MethodDesign method) { + bool overridesType = method.TypeDefinition != null || + method.MethodType != null; + bool overridesArguments = + MethodDesignArgumentResolver.HasDeclaredArguments(method); + bool overridesDeclaration = method.MethodDeclarationNode != null; + + if (overridesType || overridesArguments || overridesDeclaration) + { + MethodDesign inheritedDeclaration = mergedMethod.MethodDeclarationNode; + mergedMethod.TypeDefinition = method.TypeDefinition; + mergedMethod.TypeDefinitionNode = null; + mergedMethod.MethodType = method.MethodType; + // An override that keeps the inherited signature stays callable + // with the MethodId of the declaration it overrides, so that + // link is preserved. Only a signature change makes the override + // a declaration in its own right - pointing the link at the + // override otherwise would make MethodDeclarationId self + // referential and the supertype's MethodId would stop + // resolving in Call. + mergedMethod.MethodDeclarationNode = + method.MethodDeclarationNode ?? + (inheritedDeclaration != null && + MethodDesignArgumentResolver.HaveSameMethodSignature( + inheritedDeclaration, + method) + ? inheritedDeclaration + : mergedMethod); + mergedMethod.InputArguments = + MethodDesignArgumentResolver.ResolveMethodInputs(method); + mergedMethod.OutputArguments = + MethodDesignArgumentResolver.ResolveMethodOutputs(method); + } + mergedMethod.HasArguments = + MethodDesignArgumentResolver.HasMethodArguments(mergedMethod); + if (method.NonExecutableSpecified) { mergedMethod.NonExecutable = method.NonExecutable; @@ -5660,12 +5713,13 @@ private void BuildInstanceHierarchy( depth + 1); } - if (parent.TypeDefinition != null && parent is MethodDesign) + if (parent.TypeDefinition != null && parent is MethodDesign method) { - MethodDesign methodType = this.FindNode( - parent.TypeDefinition, - parent.SymbolicId.Name, - "MethodType"); + MethodDesign methodType = method.MethodType ?? + this.FindNode( + parent.TypeDefinition, + parent.SymbolicId.Name, + "MethodType"); if (methodType != null) { @@ -6374,16 +6428,79 @@ private static InstanceDesign MaterialiseDependencyChild( } else if (instance is MethodDesign method) { - // Methods carry their own argument lists; the TypeDefinition - // would point at a MethodType (a separate MethodDesign in - // the upstream model) which is NOT carried by the dependency - // payload, so clear it to avoid a downstream - // FindNode failure during ValidateInstance. - method.TypeDefinition = null; method.InputArguments = MaterialiseMethodArgs(c.InputArguments); method.OutputArguments = MaterialiseMethodArgs(c.OutputArguments); method.HasArguments = method.InputArguments.Length > 0 || method.OutputArguments.Length > 0; + + if (!string.IsNullOrEmpty(c.MethodStateName)) + { + var methodStateIdentity = new XmlQualifiedName( + c.MethodStateName, + c.MethodStateNamespace ?? string.Empty); + var methodState = new MethodDesign + { + SymbolicId = methodStateIdentity, + SymbolicName = methodStateIdentity, + BrowseName = c.MethodStateName, + InputArguments = method.InputArguments, + OutputArguments = method.OutputArguments, + HasArguments = method.HasArguments, + IsDeclaration = true + }; + + if (method.TypeDefinition != null) + { + method.MethodType = methodState; + } + else + { + method.MethodDeclarationNode = methodState; + } + } + else + { + // Legacy payloads flattened method arguments without + // preserving the upstream method state identity. + method.TypeDefinition = null; + } + + if (!string.IsNullOrEmpty(c.MethodDeclarationName)) + { + var declarationIdentity = new XmlQualifiedName( + c.MethodDeclarationName, + c.MethodDeclarationNamespace ?? string.Empty); + MethodDesign declaration = + method.MethodDeclarationNode?.SymbolicId == declarationIdentity + ? method.MethodDeclarationNode + : new MethodDesign + { + SymbolicId = declarationIdentity, + SymbolicName = declarationIdentity, + BrowseName = c.MethodDeclarationName, + InputArguments = method.InputArguments, + OutputArguments = method.OutputArguments, + HasArguments = method.HasArguments, + IsDeclaration = true + }; + if (method.TypeDefinition == null && + method.MethodDeclarationNode != null && + !ReferenceEquals(declaration, method.MethodDeclarationNode)) + { + declaration.MethodDeclarationNode = + method.MethodDeclarationNode; + } + if (c.MethodDeclarationNumericId != 0) + { + declaration.NumericId = c.MethodDeclarationNumericId; + declaration.NumericIdSpecified = true; + } + if (!string.IsNullOrEmpty(c.MethodDeclarationStringId)) + { + declaration.StringId = c.MethodDeclarationStringId; + } + method.MethodDeclarationNode = declaration; + } } // Mark as inherited-declaration so consumer code paths // that iterate children for emission can short-circuit. @@ -6464,6 +6581,28 @@ private void LinkDependencyChildren() { variable.DataTypeNode = dtNode as DataTypeDesign; } + if (instance is MethodDesign method) + { + LinkDependencyMethodArguments(method.InputArguments); + LinkDependencyMethodArguments(method.OutputArguments); + } + } + } + } + + private void LinkDependencyMethodArguments(Parameter[] arguments) + { + if (arguments == null) + { + return; + } + foreach (Parameter argument in arguments) + { + if (argument?.DataType != null && + argument.DataTypeNode == null && + m_nodes.TryGetValue(argument.DataType, out NodeDesign dataType)) + { + argument.DataTypeNode = dataType as DataTypeDesign; } } } diff --git a/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs b/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs index 58da96cdc4..7254f02dfd 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs @@ -1341,6 +1341,11 @@ private NodeDesign ImportNode(UANode input) output.SymbolicName = ImportSymbolicName(input); output.Extensions = input.Extensions; + output.SymbolicName = NormalizeSymbolicNameNamespace( + input, + output.SymbolicId, + output.SymbolicName); + if (input is UAType && output.SymbolicId.Name.EndsWith( "_" + nodeId.IdentifierAsString, StringComparison.Ordinal)) @@ -1410,6 +1415,22 @@ private NodeDesign ImportNode(UANode input) return output; } + internal static XmlQualifiedName NormalizeSymbolicNameNamespace( + UANode input, + XmlQualifiedName symbolicId, + XmlQualifiedName symbolicName) + { + if (input is UAType && + symbolicId != null && + symbolicName != null && + symbolicId.Namespace != symbolicName.Namespace) + { + return new XmlQualifiedName(symbolicName.Name, symbolicId.Namespace); + } + + return symbolicName; + } + private UANode FindNode(UANodeSet nodeset, ExpandedNodeId targetId) { if (targetId.IsNull) @@ -2006,7 +2027,10 @@ public ModelDesign Import(string prefix, string name) Dictionary methods = []; CollectMethodDefinitions(dictionary.TargetNamespace, methods); - items.AddRange(methods.Values); + items.AddRange(methods + .OrderBy(entry => entry.Key.Namespace, StringComparer.Ordinal) + .ThenBy(entry => entry.Key.Name, StringComparer.Ordinal) + .Select(entry => entry.Value)); foreach (NodeDesign item in items) { @@ -2142,47 +2166,131 @@ private void CollectMethodDefinitions( string targetNamespace, Dictionary methods) { - foreach (NodeDesign node in m_settings.NodesById.Values) - { - if (node is MethodDesign method) - { - if (node.SymbolicId.Namespace != targetNamespace) + MethodDesign[] candidates = + [ + .. m_settings.NodesById.Values + .OfType() + .Where(method => + method.SymbolicId.Namespace == targetNamespace && + MethodDesignArgumentResolver.HasDeclaredArguments(method)) + .OrderBy(method => method.SymbolicId.Namespace, StringComparer.Ordinal) + .ThenBy(method => method.SymbolicId.Name, StringComparer.Ordinal) + ]; + var groupedCandidates = + new Dictionary>>(); + + foreach (MethodDesign method in candidates) + { + if (method.MethodDeclarationNode != null) + { + MethodDesign definition = + MethodDesignArgumentResolver.ResolveMethodDefinition( + method.MethodDeclarationNode); + if (MethodDesignArgumentResolver.HaveSameDeclaredSignature( + method, + definition)) { continue; } - if (method.HasArguments && method.MethodDeclarationNode == null) - { - var name = new XmlQualifiedName( - node.SymbolicName.Name + "MethodType", - node.SymbolicId.Namespace); + } - if (methods.ContainsKey(name)) - { - continue; - } + var baseName = new XmlQualifiedName( + method.SymbolicName.Name + "MethodType", + method.SymbolicId.Namespace); + if (!groupedCandidates.TryGetValue( + baseName, + out List> signatureGroups)) + { + groupedCandidates.Add(baseName, signatureGroups = []); + } - var declaration = new MethodDesign + List signatureGroup = signatureGroups.FirstOrDefault( + group => MethodDesignArgumentResolver.HaveSameDeclaredSignature( + group[0], + method)); + if (signatureGroup == null) + { + signatureGroups.Add([method]); + } + else + { + signatureGroup.Add(method); + } + } + + var reservedNames = new HashSet( + m_settings.NodesByQName.Keys); + foreach (KeyValuePair>> entry + in groupedCandidates + .OrderBy(entry => entry.Key.Namespace, StringComparer.Ordinal) + .ThenBy(entry => entry.Key.Name, StringComparer.Ordinal)) + { + bool qualifyWithOwner = entry.Value.Count > 1 || + reservedNames.Contains(entry.Key); + foreach (List signatureGroup in entry.Value) + { + MethodDesign representative = signatureGroup[0]; + string preferredName = qualifyWithOwner + ? GetOwnerQualifiedMethodTypeName(representative) + : entry.Key.Name; + var name = new XmlQualifiedName( + preferredName, + representative.SymbolicId.Namespace); + int suffix = 2; + while (reservedNames.Contains(name) || methods.ContainsKey(name)) + { + string stem = preferredName.EndsWith( + "MethodType", + StringComparison.Ordinal) + ? preferredName[..^"MethodType".Length] + : preferredName; + name = new XmlQualifiedName( + stem + + suffix.ToString(CultureInfo.InvariantCulture) + + "MethodType", + representative.SymbolicId.Namespace); + suffix++; + } + + var declaration = new MethodDesign + { + SymbolicId = name, + SymbolicName = name, + BrowseName = name.Name, + DisplayName = new LocalizedText { - SymbolicId = name, - SymbolicName = name, - BrowseName = name.Name, - DisplayName = new LocalizedText - { - Value = name.Name, - IsAutogenerated = true - }, - InputArguments = method.InputArguments, - OutputArguments = method.OutputArguments, - HasArguments = true - }; + Value = name.Name, + IsAutogenerated = true + }, + InputArguments = representative.InputArguments, + OutputArguments = representative.OutputArguments, + HasArguments = true + }; - methods.Add(declaration.SymbolicName, declaration); + methods.Add(declaration.SymbolicName, declaration); + reservedNames.Add(declaration.SymbolicName); + foreach (MethodDesign method in signatureGroup) + { method.MethodDeclarationNode = declaration; + method.TypeDefinition = null; + method.MethodType = null; } } } } + private static string GetOwnerQualifiedMethodTypeName(MethodDesign method) + { + string ownerName = method.SymbolicId.Name; + string methodSuffix = "_" + method.SymbolicName.Name; + if (ownerName.EndsWith(methodSuffix, StringComparison.Ordinal)) + { + ownerName = ownerName[..^methodSuffix.Length]; + } + ownerName = ownerName.Replace("_", string.Empty, StringComparison.Ordinal); + return method.SymbolicName.Name + ownerName + "MethodType"; + } + /// /// Creates an decoder to restore Variant values. /// diff --git a/tools/Opc.Ua.SourceGeneration.Core/Templating/Tokens.cs b/tools/Opc.Ua.SourceGeneration.Core/Templating/Tokens.cs index 73c8adfe1e..81f89673ba 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Templating/Tokens.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Templating/Tokens.cs @@ -69,6 +69,10 @@ internal static class Tokens public static string Documentation => nameof(Documentation); public static string EmitDefaultValue => nameof(EmitDefaultValue); public static string EncodingMaskModifier => nameof(EncodingMaskModifier); + public static string EncodingMaskFieldNamesModifier => + nameof(EncodingMaskFieldNamesModifier); + public static string EncodingMaskEncode => nameof(EncodingMaskEncode); + public static string EncodingMaskDecode => nameof(EncodingMaskDecode); public static string EnumerationName => nameof(EnumerationName); public static string EventNotifier => nameof(EventNotifier); public static string FieldIndex => nameof(FieldIndex); diff --git a/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md b/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md index 6afcf1913a..170711882f 100644 --- a/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md +++ b/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md @@ -11,6 +11,8 @@ MODELGEN010 | ModelSourceGenerator | Warning | [NodeManager] attribute binding e MODELGEN011 | ModelSourceGenerator | Error | [NodeManager] class must be partial MODELGEN012 | ModelSourceGenerator | Info | Multiple referenced assemblies expose same model MODELGEN013 | ModelSourceGenerator | Info | Model already provided by referenced assembly +MODELGEN014 | ModelSourceGenerator | Error | Fluent-accessors-only model cannot be generated +MODELGEN015 | ModelSourceGenerator | Error | Invalid fluent-accessors-only configuration MODELGEN020 | ModelSourceGenerator | Warning | BrowseName requires C# string-literal escaping (UASG_BROWSENAME_UNSAFE) MODELGEN021 | ModelSourceGenerator | Error | [DataType] namespace could not be resolved MODELGEN022 | ModelSourceGenerator | Error | NodeSet identifier file is missing diff --git a/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs b/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs index aaf6d6c891..530ab12337 100644 --- a/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs +++ b/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs @@ -62,6 +62,7 @@ public ModelCompilation( ModelCompilationOptions options, CompilationOptions compilationOptions, ImmutableArray referencedModels, + ImmutableArray referencedAccessorProviders, ImmutableArray nodeManagerBindings, ImmutableHashSet availableStateTypeNames, ILogger logger) @@ -74,6 +75,7 @@ public ModelCompilation( m_compilationOptions = compilationOptions; m_nodeManagerBindings = nodeManagerBindings; m_referencedModels = referencedModels; + m_referencedAccessorProviders = referencedAccessorProviders; m_availableStateTypeNames = availableStateTypeNames; m_telemetry = SourceGeneratorTelemetry.Create(logger, m_context); } @@ -120,6 +122,7 @@ public void Emit(CancellationToken cancellationToken) m_options.UseTypeDefinitionModellingRules, EmitDependencyMetadata = ResolveEmitDependencyMetadata(), OmitFluentApi = m_options.OmitFluentApi, + FluentAccessorsOnly = m_options.FluentAccessorsOnly, OmitEventRecords = m_options.OmitEventRecords }; @@ -171,13 +174,29 @@ void reportBinding(NodeManagerAttributeBinding binding, string message) message)); } + void reportFluentAccessorsOnly( + string modelUri, + string prefix, + string path, + string reason) + { + m_context.ReportDiagnostic( + Diagnostic.Create( + SourceGenerator.FluentAccessorsOnlyModelError, + Location.None, + modelUri, + prefix, + path, + reason)); + } + // Reduce referenced model attributes to a single dictionary by // model URI (with tie-break on highest version+publication date) // so the downstream generators can apply override resolution. IReadOnlyDictionary referencedModels = BuildReferencedModelMap(); IReadOnlyDictionary - referencedDependencies = BuildReferencedDependencyMap(); + referencedDependencies = BuildReferencedDependencyMap(referencedModels); // The design files that are not NodeSet2 inputs form the // ModelDesign pass. Compute them up front so the total model @@ -227,7 +246,10 @@ void reportBinding(NodeManagerAttributeBinding binding, string message) bindings.Count > 0 ? reportBinding : null, referencedDependencies, usedBindings, - totalModelCount); + totalModelCount, + reportFluentAccessorsOnly, + m_referencedModels, + m_referencedAccessorProviders); // Process the remaining design files. Every NodeSet2 input // (encoded with the prefix/name computed by the nodeset @@ -252,7 +274,10 @@ [.. m_identifierFiles.Select(i => i.Path)], bindings.Count > 0 ? bindings : null, bindings.Count > 0 ? reportBinding : null, usedBindings, - totalModelCount); + totalModelCount, + reportFluentAccessorsOnly, + m_referencedModels, + m_referencedAccessorProviders); // Report any [NodeManager] bindings that neither pass matched, // once, against the shared used-set aggregated across passes. @@ -296,6 +321,26 @@ private bool CheckCompilationOptions() "Minimum required language version is CSharp 8.")); return false; } + if (m_options.FluentAccessorsOnly && m_options.OmitFluentApi) + { + m_context.ReportDiagnostic( + Diagnostic.Create( + SourceGenerator.FluentAccessorsOnlyOptionsError, + Location.None, + "ModelSourceGeneratorFluentAccessorsOnly and " + + "ModelSourceGeneratorOmitFluentApi cannot both be enabled.")); + return false; + } + if (m_options.FluentAccessorsOnly && m_options.Options.GenerateNodeManager) + { + m_context.ReportDiagnostic( + Diagnostic.Create( + SourceGenerator.FluentAccessorsOnlyOptionsError, + Location.None, + "ModelSourceGeneratorFluentAccessorsOnly cannot be combined with " + + "ModelSourceGeneratorGenerateNodeManager.")); + return false; + } return true; } @@ -334,9 +379,10 @@ private void ReportNodesetIdentifierDiagnostics( /// /// Group the referenced-assembly attributes by model URI; when more - /// than one assembly contributes the same URI, prefer the entry with - /// the highest (Version, PublicationDate) lexicographic tuple - /// per the contract on . + /// than one assembly contributes the same URI, prefer a payload-bearing + /// self producer over payloadless transitive re-exports, then use the + /// highest (Version, PublicationDate) lexicographic tuple per + /// the contract on . /// private IReadOnlyDictionary BuildReferencedModelMap() @@ -358,7 +404,13 @@ private IReadOnlyDictionary map[candidate.ModelUri] = candidate; continue; } - int cmp = string.CompareOrdinal(candidate.Version, existing.Version); + bool candidateIsProducer = IsModelProducer(candidate); + bool existingIsProducer = IsModelProducer(existing); + int cmp = candidateIsProducer.CompareTo(existingIsProducer); + if (cmp == 0) + { + cmp = string.CompareOrdinal(candidate.Version, existing.Version); + } if (cmp == 0) { cmp = string.CompareOrdinal( @@ -396,29 +448,35 @@ private IReadOnlyDictionary /// silently dropped. /// private IReadOnlyDictionary - BuildReferencedDependencyMap() + BuildReferencedDependencyMap( + IReadOnlyDictionary referencedModels) { - if (m_referencedModels.IsDefaultOrEmpty) + if (referencedModels.Count == 0) { return ImmutableDictionary.Empty; } var map = new Dictionary( StringComparer.Ordinal); - foreach (ModelDependencyReference candidate in m_referencedModels) + foreach (KeyValuePair entry in referencedModels) { - ModelDependencyV1 decoded = candidate.GetDependency(); - if (decoded == null) + ModelDependencyV1 decoded = entry.Value.GetDependency(); + if (decoded == null || + !string.Equals(decoded.ModelUri, entry.Key, StringComparison.Ordinal)) { continue; } - if (!map.ContainsKey(candidate.ModelUri)) - { - map[candidate.ModelUri] = decoded; - } + map[entry.Key] = decoded; } return map; } + private static bool IsModelProducer(ModelDependencyReference reference) + { + ModelDependencyV1 dependency = reference.GetDependency(); + return dependency != null && + string.Equals(dependency.ModelUri, reference.ModelUri, StringComparison.Ordinal); + } + /// /// Resolve whether the model-dependency assembly attribute /// should be emitted, honouring the compilation's OutputKind @@ -443,6 +501,8 @@ OutputKind.DynamicallyLinkedLibrary or private readonly ModelCompilationOptions m_options; private readonly CompilationOptions m_compilationOptions; private readonly ImmutableArray m_referencedModels; + private readonly ImmutableArray + m_referencedAccessorProviders; private readonly ImmutableArray m_nodeManagerBindings; private readonly ImmutableHashSet m_availableStateTypeNames; private readonly SourceGeneratorTelemetry m_telemetry; diff --git a/tools/Opc.Ua.SourceGeneration/ModelCompilationOptions.cs b/tools/Opc.Ua.SourceGeneration/ModelCompilationOptions.cs index 0a87d90dba..e86f79ee01 100644 --- a/tools/Opc.Ua.SourceGeneration/ModelCompilationOptions.cs +++ b/tools/Opc.Ua.SourceGeneration/ModelCompilationOptions.cs @@ -147,6 +147,19 @@ internal sealed record class ModelCompilationOptions /// public bool OmitFluentApi { get; set; } + /// + /// When set to true, a local model already supplied by a + /// referenced assembly under the same model URI and C# prefix is + /// loaded and validated, but only its per-ObjectType typed fluent + /// accessor extension classes are emitted. The accessors reference + /// the state types from the referenced model assembly. The referenced + /// model metadata must explicitly declare that its producer omitted + /// fluent accessors. + /// Surfaced from MSBuild via the + /// ModelSourceGeneratorFluentAccessorsOnly property. + /// + public bool FluentAccessorsOnly { get; set; } + /// /// Suppresses event-record generation. /// @@ -187,6 +200,8 @@ public static ModelCompilationOptions From(AnalyzerConfigOptionsProvider provide nameof(EmitDependencyMetadata))), OmitFluentApi = provider.GlobalOptions.GetBool( nameof(OmitFluentApi)), + FluentAccessorsOnly = provider.GlobalOptions.GetBool( + nameof(FluentAccessorsOnly)), OmitEventRecords = provider.GlobalOptions.GetBool( nameof(OmitEventRecords)) }; diff --git a/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs b/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs index fed1637afd..41d9cc4fb2 100644 --- a/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs @@ -78,6 +78,9 @@ public override void OnInitialize(IncrementalGeneratorInitializationContext cont IncrementalValueProvider> referencedModels = context.CompilationProvider .Select((c, _) => ReferencedModelDependencyScanner.Scan(c)); + IncrementalValueProvider> + referencedAccessorProviders = context.CompilationProvider + .Select((c, _) => ReferencedFluentAccessorProviderScanner.Scan(c)); IncrementalValueProvider> stateTypeIndex = context.CompilationProvider .Select((c, _) => OpcUaStateTypeIndex.Build(c)); @@ -111,21 +114,30 @@ public override void OnInitialize(IncrementalGeneratorInitializationContext cont IncrementalValueProvider< ( ImmutableArray ReferencedModels, + ImmutableArray ReferencedAccessorProviders, ImmutableArray NodeManagerBindings)> modelReferences = referencedModels - .Combine(nodeManagerBindings) + .Combine(referencedAccessorProviders) .Select(static (pair, _) => ( ReferencedModels: pair.Left, + ReferencedAccessorProviders: pair.Right)) + .Combine(nodeManagerBindings) + .Select(static (pair, _) => ( + ReferencedModels: pair.Left.ReferencedModels, + ReferencedAccessorProviders: pair.Left.ReferencedAccessorProviders, NodeManagerBindings: pair.Right)); IncrementalValueProvider< ( ImmutableArray ReferencedModels, + ImmutableArray ReferencedAccessorProviders, ImmutableArray NodeManagerBindings, ImmutableHashSet AvailableStateTypeNames)> modelDependencies = modelReferences .Combine(stateTypeIndex) - .Select(static (pair, _) => (pair.Left.ReferencedModels, - pair.Left.NodeManagerBindings, + .Select(static (pair, _) => ( + ReferencedModels: pair.Left.ReferencedModels, + ReferencedAccessorProviders: pair.Left.ReferencedAccessorProviders, + NodeManagerBindings: pair.Left.NodeManagerBindings, AvailableStateTypeNames: pair.Right)); IncrementalValueProvider< ( @@ -150,6 +162,7 @@ public override void OnInitialize(IncrementalGeneratorInitializationContext cont pair.Left.Options, pair.Left.CompilationOptions, pair.Right.ReferencedModels, + pair.Right.ReferencedAccessorProviders, pair.Right.NodeManagerBindings, pair.Right.AvailableStateTypeNames)); @@ -163,6 +176,7 @@ public override void OnInitialize(IncrementalGeneratorInitializationContext cont input.Options, input.CompilationOptions, input.ReferencedModels, + input.ReferencedAccessorProviders, input.NodeManagerBindings, input.AvailableStateTypeNames, Logger).Emit(context.CancellationToken)); @@ -190,6 +204,7 @@ private readonly record struct ModelCompilationInput( ModelCompilationOptions Options, CompilationOptions CompilationOptions, ImmutableArray ReferencedModels, + ImmutableArray ReferencedAccessorProviders, ImmutableArray NodeManagerBindings, ImmutableHashSet AvailableStateTypeNames); } diff --git a/tools/Opc.Ua.SourceGeneration/OPCFoundation.Opc.Ua.SourceGeneration.props b/tools/Opc.Ua.SourceGeneration/OPCFoundation.Opc.Ua.SourceGeneration.props index f1f62b9a68..8972b7080e 100644 --- a/tools/Opc.Ua.SourceGeneration/OPCFoundation.Opc.Ua.SourceGeneration.props +++ b/tools/Opc.Ua.SourceGeneration/OPCFoundation.Opc.Ua.SourceGeneration.props @@ -14,6 +14,7 @@ + diff --git a/tools/Opc.Ua.SourceGeneration/ReferencedFluentAccessorProviderScanner.cs b/tools/Opc.Ua.SourceGeneration/ReferencedFluentAccessorProviderScanner.cs new file mode 100644 index 0000000000..de83ad297f --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration/ReferencedFluentAccessorProviderScanner.cs @@ -0,0 +1,89 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Opc.Ua.SourceGeneration +{ + /// + /// Scans referenced assemblies for generated fluent-accessor providers. + /// + internal static class ReferencedFluentAccessorProviderScanner + { + public const string AttributeMetadataName = + "Opc.Ua.ModelFluentAccessorProviderAttribute"; + + /// + /// Returns every valid accessor-provider declaration. + /// + public static ImmutableArray Scan( + Compilation compilation) + { + if (compilation == null) + { + return []; + } + INamedTypeSymbol attrType = compilation.GetTypeByMetadataName( + AttributeMetadataName); + if (attrType == null) + { + return []; + } + + ImmutableArray.Builder results = + ImmutableArray.CreateBuilder(); + foreach (IAssemblySymbol assembly in + compilation.SourceModule.ReferencedAssemblySymbols) + { + foreach (AttributeData attr in assembly.GetAttributes()) + { + if (!SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attrType)) + { + continue; + } + ImmutableArray args = attr.ConstructorArguments; + if (args.Length < 2) + { + continue; + } + var reference = new ModelFluentAccessorProviderReference( + assembly.Identity.Name, + args[0].Value as string ?? string.Empty, + args[1].Value as string ?? string.Empty); + if (reference.IsValid) + { + results.Add(reference); + } + } + } + return results.ToImmutable(); + } + } +} diff --git a/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs b/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs index 9502f48c1c..c8fae0a3bc 100644 --- a/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs @@ -80,6 +80,27 @@ internal static class SourceGenerator helpLinkUri: "www.opcfoundation.org", customTags: ["opcua"]); + public static readonly DiagnosticDescriptor FluentAccessorsOnlyModelError = new( + id: "MODELGEN014", + title: "Fluent-accessors-only model cannot be generated", + messageFormat: (LocalizableString)("Cannot generate fluent accessors only for model " + + "URI '{0}', prefix '{1}', input '{2}': {3}"), + category: Name, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: "www.opcfoundation.org", + customTags: ["opcua"]); + + public static readonly DiagnosticDescriptor FluentAccessorsOnlyOptionsError = new( + id: "MODELGEN015", + title: "Invalid fluent-accessors-only configuration", + messageFormat: (LocalizableString)"Invalid fluent-accessors-only configuration: {0}", + category: Name, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: "www.opcfoundation.org", + customTags: ["opcua"]); + public static readonly DiagnosticDescriptor ModelDependencyTieBreak = new( id: "MODELGEN012", title: "Multiple referenced assemblies expose same model",