mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
668310f21f
# Description The fluent node-manager pipeline had two synchronous chokepoints, and two independent efforts have now hit both of them. `Configure` is a `partial void`, so it cannot `await`. A manager whose wiring depends on asynchronous setup had to invent a second hook outside the pipeline, create its own builder there, and hand-roll `CreateFluentBuilder(...).Configure(...).Seal()` inside it. `Seal()` was synchronous, so nothing a registration staged during `Configure` could be completed by awaiting. Registrations that needed to await either blocked — `EventSourceRegistry` did `.GetAwaiter().GetResult()` on the manager's root-notifier registration — or could not be expressed at all. ## What changed **`FluentNodeManagerBase.ConfigureAsync(INodeManagerBuilder, CancellationToken)`** is the awaitable wiring seam. It runs once per activation, with the manager's attached builder, immediately *before* the synchronous `Configure` partial(s) — so instances it materialises are in the address space by the time `Configure` wires callbacks against them. The default implementation is a no-op. It is a `virtual` method rather than a second `partial` declaration because a partial method can be optional (`partial void`, no return value) or awaitable (an extended partial method, which must be implemented) — not both, and the generator would otherwise have to detect which one the user declared. `partial void Configure(INodeManagerBuilder)` keeps working unchanged; a manager uses either hook or both. **`NodeManagerBuilder.Seal()` becomes `SealAsync(CancellationToken)`.** Sealing is now the single point where registrations that could not finish inside `Configure` get their turn to await. Every seal site awaits it, so a later change can move registry *setup* onto the same seam. **`DiNodeManager` now runs the standard pipeline itself** — create and attach the builder, `await ConfigureAsync`, `RegisterAuthoredNodesAsync`, `CompleteConfigureAsync`, the post-setup runner, then `await builder.SealAsync` — instead of handing subclasses a bare callback and leaving them to build and seal on their own. DI subclasses gain node registration and the reverse-reference pass, which they previously skipped. Sealing deliberately comes **after** the post-setup runner. The fluent registries are owned by the manager, not by a single builder, and sealing starts them — so sealing first locks post-setup configurators out of registering simulation loops of their own, which is exactly what `AddRobotics`' configurators do. Sealing before the runner makes `MinimalRobotServer` fail to start with *"Cannot add a simulation loop after the registry has started"*. Worth knowing for follow-up work: with several builders attached to one manager, the first seal freezes the shared registry for all of them. ## Workarounds removed - **`DiNodeManager.OnAddressSpaceReadyAsync`** — deleted. Its three overriders (`RoboticsNodeManager`, and the `PumpNodeManager` / `GeneratorNodeManager` samples) now override `ConfigureAsync` and no longer hand-roll `CreateFluentBuilder(...).Configure(Configure).Seal()`. - **`EventSourceRegistry`'s `.GetAwaiter().GetResult()`** on `AddRootNotifierAsync` — gone. `Publish(..., RegisterAsRootNotifier: true)` now stages the registration and `SealAsync` drains it, so the wait happens on an awaited path. A source whose root-notifier registration fails is rolled out of the registry before the failure surfaces, and a registry disposed before its builder sealed drops what it staged. - **`Isa95NodeManager.ConfigureStatusEvents()`** — the synchronous second builder pass is now `ConfigureStatusEventsAsync(CancellationToken)`, awaited from `CreateAddressSpaceAsync`. (`ConfigureCatalogChanges()` stays synchronous: it starts a background task and never seals a builder, so there was nothing to convert.) - **`IRoboticsBuildContext.Seal()`** — now `SealAsync(CancellationToken)`. These really were `NodeManagerBuilder.Seal()` calls behind a same-named method. The state transition still happens under the context lock; the builder's asynchronous completion runs outside it, so no `await` is taken while the lock is held. - The Pump / Generator incremental registration callbacks (`onRegistered`) changed from `Action<T>` to `Func<T, CancellationToken, ValueTask>` so their per-instance builder pass can await its own seal. No sync-over-async was introduced anywhere. `Dispose(bool)` is unchanged and stays non-blocking; teardown remains in `DeleteAddressSpaceAsync`. `NodeManagerBuilder.Seal()` and `IRoboticsBuildContext.Seal()` are removed rather than kept as synchronous overloads. A synchronous seal that silently skipped the asynchronous completion would reintroduce exactly the failure mode this change exists to remove. ## Generator `NodeManagerTemplates` emits, in order: predefined nodes load → `await ConfigureAsync(__m_builder, cancellationToken)` → `Configure(__m_builder)` and the typed `Configure` → `RegisterAuthoredNodesAsync` → `CompleteConfigureAsync` → `await SealConfigurationAsync(__m_builder, cancellationToken)`. `NodeManagerGeneratorTests` asserts that order literally, and that neither `__m_builder.Seal()` nor the synchronous `SealConfiguration(__m_builder)` is emitted. ### Merging with #4432 #4432 landed on `master` while this was in review and reworked the same seam from the other side: it split `Seal()` into `SealGraphAuthoring()` + `StartSimulations()` so a manager can replay `NotifyNodeAdded` *between* the two halves — sealing first stops a lifecycle handler authoring nodes nothing would register, starting the simulations last stops a simulated value change preceding its own node''s `OnNodeAdded` — and added `FluentNodeManagerBase.SealConfiguration(builder)` as the one call the generator emits. Both intents are kept. That split is precisely why the asynchronous completion could not simply live inside `SealAsync`: a manager reaching the seam through the split path would silently skip it, which is the failure mode this PR exists to remove. So the asynchronous half became its own step: - `NodeManagerBuilder.CompleteSealAsync(ct)` drains the staged root-notifier registrations, then starts the simulations. `SealAsync(ct)` is `SealGraphAuthoring()` followed by it. - #4432''s synchronous `StartSimulations()` is gone, folded into `CompleteSealAsync`. It had exactly one production caller after the merge, and leaving it standing would have kept a second, synchronous way to activate a builder that skips the staged registrations — the very hazard this PR removes. Activation is now one step with one entry point; the *split* itself stays, because the replay has to sit between the halves. - `SealConfiguration` becomes `SealConfigurationAsync(builder, ct)`: seal the graph, replay `NotifyNodeAdded`, then `CompleteSealAsync`. Activation still happens last, so #4432''s ordering guarantee holds unchanged. ## Consumers unblocked - **#4434 ("Reimplement the GDS ApplicationsNodeManager as a generated fluent node manager")** adds `OnAddressSpaceReadyAsync` to `FluentNodeManagerBase` purely because, in its own words, *"`Configure` is a `partial void` and cannot await"*. That hook is unnecessary once `ConfigureAsync` exists — the GDS manager overrides `ConfigureAsync` instead, and the ordering it wants (predefined nodes → async setup → `Configure`) is what the generator now emits. #4434 has not merged yet, so it can drop its addition rather than have it simplified afterwards. - **Branch `romanett/fluent-node-behaviors`** moved `SimulationRegistry`, `EventSourceRegistry`, `MonitoredSourceRegistry` and the alarm wiring onto `AttachToType`/`Attach` for *release* only, because activation ran from `CompleteConfigureAsync` and most managers only called the synchronous `Seal()`. Every seal site now reaches `SealAsync`, so registry and alarm *setup* can move onto behavior activation too — the remaining half of that work. ## Tests - New `DiNodeManagerConfigureAsyncTests` drives the real `DiNodeManager.CreateAddressSpaceAsync` and asserts the hook runs once with an attached builder, that nodes it stages are registered, that their references to externally owned nodes are published, and that the builder is sealed before `CreateAddressSpaceAsync` returns. - `PublishTests` now pins the root-notifier deferral contract: registration is staged during `Configure`, lands only on the seal, and draining twice does not double-register. - Every `Seal()` call site in the test suites became `await ...SealAsync()`, including the three #4432 added in `NodeSetImportBuilderTests` and `SimulationBuilderExtensionsTests`. ## Related Issues No tracking issue was opened for this; it is the shared prerequisite extracted from #4434 and branch `romanett/fluent-node-behaviors`, both of which had independently worked around the same synchrony. Happy to open one if maintainers want the ADR trail. ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. ### What was actually run locally `UA.slnx` builds clean. Post-merge on **net10.0**: `Opc.Ua.Server.Tests` 5020/0 and `Opc.Ua.SourceGeneration.Core.Tests` 3813/0. Pre-merge the same run plus `Opc.Ua.Di.Tests` 425/0, `Opc.Ua.Robotics.Tests` 627/0, `Opc.Ua.ISA95.Tests` 136/0, `Opc.Ua.Positioning.Tests` and `Opc.Ua.Robotics.Intent.Tests` were all green; `MinimalRobotServer` was also started directly to confirm the hosted robotics path comes up. Three caveats, so the unchecked box above is not a mystery: the full suite was **not** run against .NET Framework locally; `Opc.Ua.OpenUsd.Tests` has one pre-existing failure on this machine (`StructuredCartesianCoordinatesAreAccepted` expects `"1.5"` and gets `"1,5"` — a German-locale decimal separator, unrelated to this change); and one pre-existing `CA1861` warning comes from `NodeSetImportIntegrationTests.cs`, which is byte-identical to `master` on this branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
804 lines
39 KiB
C#
804 lines
39 KiB
C#
/* ========================================================================
|
|
* 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.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
|
|
{
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public partial class PumpNodeManager
|
|
{
|
|
private OpenUsdRootState? m_openUsdRoot;
|
|
private OpenUsdStageState? m_plantStage;
|
|
|
|
/// <summary>
|
|
/// Everything the OpenUSD twin needs to render one simulated pump: the
|
|
/// prim the pump composes into, the hand-built signal Variables that
|
|
/// feed its bindings, and the simulation those signals read. One
|
|
/// instance exists per configured pump, so a server started with
|
|
/// <c>--pumps N</c> drives N independently animated machines.
|
|
/// </summary>
|
|
private sealed class PumpTwin
|
|
{
|
|
public PumpTwin(string primPath, int pumpNumber)
|
|
{
|
|
PrimPath = primPath;
|
|
PumpNumber = pumpNumber;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Absolute prim path of this pump on the plant stage.
|
|
/// </summary>
|
|
public string PrimPath { get; }
|
|
|
|
/// <summary>
|
|
/// One-based number of the simulated unit.
|
|
/// </summary>
|
|
public int PumpNumber { get; }
|
|
|
|
public BaseDataVariableState? AlarmActive { get; set; }
|
|
|
|
public BaseDataVariableState? ShaftAngle { get; set; }
|
|
|
|
public BaseDataVariableState? SpeedSetpoint { get; set; }
|
|
|
|
public BaseDataVariableState? BayPosition { get; set; }
|
|
|
|
/// <summary>
|
|
/// Position of the liquid surface in the suction vessel, published as a
|
|
/// structured coordinate so the Translation profile can drive it.
|
|
/// </summary>
|
|
public BaseDataVariableState? FluidSurface { get; set; }
|
|
|
|
public OpenUsdRepresentationState? Representation { get; set; }
|
|
|
|
/// <summary>
|
|
/// The pump this twin renders, needed to resolve nodes the fluent pass
|
|
/// creates after the pump has been materialised.
|
|
/// </summary>
|
|
public PumpState? Pump { get; set; }
|
|
|
|
/// <summary>
|
|
/// The simulation this twin renders. Assigned by
|
|
/// <c>RegisterPumpSimulation</c>, which runs after the pump has been
|
|
/// materialised, so it stays null until the fluent pass completes.
|
|
/// </summary>
|
|
public PumpSimulationState? Simulation { get; set; }
|
|
|
|
/// <summary>
|
|
/// Shaft angular position in degrees, or zero before the simulation
|
|
/// is wired.
|
|
/// </summary>
|
|
public double ShaftAngleDegrees => Simulation?.ShaftAngleDegrees ?? 0.0;
|
|
|
|
/// <summary>
|
|
/// The supervision alarm state the status-light binding follows.
|
|
/// </summary>
|
|
public bool AlarmActiveState => Simulation?.AlarmActive ?? false;
|
|
}
|
|
|
|
// Written while the address space is built and while a pump is added at
|
|
// runtime; read by the 250 ms simulation tick.
|
|
private readonly ConcurrentDictionary<NodeId, PumpTwin> m_twins = new();
|
|
|
|
/// <summary>
|
|
/// Rate at which the server samples the hand-built OpenUSD signals, in
|
|
/// milliseconds. Matches the simulation tick so every change is observed.
|
|
/// </summary>
|
|
private const double SignalSamplingIntervalMilliseconds = 250;
|
|
|
|
/// <summary>
|
|
/// 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 <c>AddPredefinedNodeAsync(pump)</c> performs: they browse
|
|
/// and read correctly but are never sampled, which leaves any binding that
|
|
/// uses them frozen at its start-up value.
|
|
/// </summary>
|
|
/// <param name="pump">The pump whose signals to register.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <remarks>
|
|
/// Registers one pump, not every pump materialised so far. This runs once
|
|
/// per pump as it is created, and the twin is already in the dictionary by
|
|
/// then, so walking the whole dictionary would re-register every earlier
|
|
/// pump and add its shaft angle to the publish set again - leaving the
|
|
/// first pump published N times per tick for N pumps.
|
|
/// </remarks>
|
|
private async ValueTask RegisterOpenUsdSignalsAsync(
|
|
PumpState pump, CancellationToken cancellationToken)
|
|
{
|
|
if (!m_twins.TryGetValue(pump.NodeId, out PumpTwin? twin))
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (BaseDataVariableState? signal in new[]
|
|
{
|
|
twin.AlarmActive, twin.ShaftAngle, twin.SpeedSetpoint,
|
|
twin.BayPosition, twin.FluidSurface
|
|
})
|
|
{
|
|
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. The closure captures this pump's twin,
|
|
// so every pump reports its own angle.
|
|
if (twin.ShaftAngle != null)
|
|
{
|
|
TrackSignal(twin.ShaftAngle, () => twin.ShaftAngleDegrees);
|
|
}
|
|
}
|
|
|
|
private const string PlantRootLayerIdentifier = "asset-repo/Plant.usd";
|
|
|
|
/// <summary>
|
|
/// Prim scope the configured pumps are composed into. Each pump owns
|
|
/// <c><PumpsScopePrimPath>/<BrowseName></c>, which the plant
|
|
/// aggregation composes from the served <c>pump.usda</c> component asset.
|
|
/// </summary>
|
|
private const string PumpsScopePrimPath = "/Plant/Pumps";
|
|
|
|
/// <summary>
|
|
/// Plant prim the DeviceSet-level representation anchors on.
|
|
/// </summary>
|
|
private const string PlantPrimPath = "/Plant";
|
|
|
|
/// <summary>
|
|
/// Spacing between two pump bays along the plant Y axis, in metres. Wide
|
|
/// enough to clear the 1.80 m baseplate with an access aisle.
|
|
/// </summary>
|
|
private const double PumpBaySpacingMetres = 2.4;
|
|
|
|
/// <summary>
|
|
/// Absolute prim path of the supplied pump.
|
|
/// </summary>
|
|
private static string PumpPrimPathFor(PumpState pump)
|
|
{
|
|
return PumpsScopePrimPath + "/" + (pump.BrowseName.Name ?? "Pump");
|
|
}
|
|
|
|
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<ServedAsset> 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<ServedAsset> LoadServedAssets()
|
|
{
|
|
return new List<ServedAsset>
|
|
{
|
|
new ServedAsset("Plant.usda", OpenUsdAssetKindEnum.RootLayer, ReadEmbeddedAsset("Plant.usda")),
|
|
new ServedAsset("pump.usda", OpenUsdAssetKindEnum.Reference, ReadEmbeddedAsset("pump.usda")),
|
|
};
|
|
}
|
|
|
|
private static byte[] ReadEmbeddedAsset(string resourceName)
|
|
{
|
|
using Stream? s = typeof(PumpNodeManager).Assembly.GetManifestResourceStream(resourceName);
|
|
if (s == null)
|
|
{
|
|
return Array.Empty<byte>();
|
|
}
|
|
using var ms = new MemoryStream();
|
|
s.CopyTo(ms);
|
|
return ms.ToArray();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override async ValueTask CreateAddressSpaceAsync(
|
|
IDictionary<NodeId, IList<IReference>> externalReferences,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// base.CreateAddressSpaceAsync loads predefined nodes and runs
|
|
// ConfigureAsync, 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<NodeId, IList<IReference>> externalReferences)
|
|
{
|
|
if (m_openUsdRoot == null)
|
|
{
|
|
return;
|
|
}
|
|
if (!externalReferences.TryGetValue(Opc.Ua.ObjectIds.Server, out IList<IReference>? references)
|
|
|| references == null)
|
|
{
|
|
externalReferences[Opc.Ua.ObjectIds.Server] = references = new List<IReference>();
|
|
}
|
|
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, int pumpNumber)
|
|
{
|
|
if (m_plantStage == null)
|
|
{
|
|
return;
|
|
}
|
|
ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD);
|
|
string primPath = PumpPrimPathFor(pump);
|
|
var twin = new PumpTwin(primPath, pumpNumber);
|
|
|
|
OpenUsdRepresentationState rep = SystemContext.CreateRepresentation(
|
|
pump, m_plantStage.NodeId, primPath, ns);
|
|
twin.Representation = rep;
|
|
twin.Pump = pump;
|
|
|
|
MeasurementsState? m = pump.Operational?.Measurements;
|
|
NodeId bearingTemp = m?.BearingTemperature?.NodeId ?? NodeId.Null;
|
|
NodeId diffPressure = m?.DifferentialPressure?.NodeId ?? NodeId.Null;
|
|
NodeId fluidTemp = m?.FluidTemperature?.NodeId ?? NodeId.Null;
|
|
NodeId massFlow = m?.MassFlow?.NodeId ?? NodeId.Null;
|
|
NodeId efficiency = m?.PumpEfficiency?.NodeId ?? NodeId.Null;
|
|
NodeId level = m?.Level?.NodeId ?? NodeId.Null;
|
|
NodeId numberOfStarts = m?.NumberOfStarts?.NodeId ?? NodeId.Null;
|
|
|
|
// Layout: each pump publishes the bay it stands in, bound as a
|
|
// translation on its own prim. That keeps the shared component asset
|
|
// position-free, so any number of pumps lays out without editing it.
|
|
twin.BayPosition = CreatePumpVariable(
|
|
pump,
|
|
"BayPosition",
|
|
Opc.Ua.DataTypeIds.ThreeDCartesianCoordinates,
|
|
Variant.From(new ExtensionObject(new ThreeDCartesianCoordinates
|
|
{
|
|
X = 0.0,
|
|
Y = (pumpNumber - 1) * PumpBaySpacingMetres,
|
|
Z = 0.0
|
|
})),
|
|
writable: false);
|
|
CreateBinding(rep, ns, "BayLayout",
|
|
GuidFor("BayLayout"),
|
|
twin.BayPosition.NodeId, primPath, "xformOp:translate", "double3",
|
|
OpenUsdRenderTargetKindEnum.Translation, 1.0);
|
|
|
|
// 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).
|
|
twin.AlarmActive = 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.
|
|
twin.AlarmActive.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds;
|
|
PumpTwin alarmTwin = twin;
|
|
twin.AlarmActive.OnSimpleReadValue =
|
|
(ISystemContext context, NodeState node, ref Variant value) =>
|
|
{
|
|
value = new Variant(alarmTwin.AlarmActiveState);
|
|
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.
|
|
twin.ShaftAngle = CreatePumpVariable(
|
|
pump, "ShaftAngle", Opc.Ua.DataTypeIds.Double, new Variant(0.0), writable: false);
|
|
twin.ShaftAngle.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds;
|
|
PumpTwin shaftTwin = twin;
|
|
twin.ShaftAngle.OnSimpleReadValue =
|
|
(ISystemContext context, NodeState node, ref Variant value) =>
|
|
{
|
|
value = new Variant(shaftTwin.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",
|
|
GuidFor("ShaftSpin"),
|
|
twin.ShaftAngle.NodeId, primPath + "/Impeller", "xformOp:rotateZ", "double",
|
|
OpenUsdRenderTargetKindEnum.Rotation, ShaftRenderScale,
|
|
sourceSemanticId: MassFlowSemanticId);
|
|
|
|
// The motor cooling fan turns with the duty point, so a pump that is
|
|
// barely moving fluid is visibly loafing.
|
|
CreateBinding(rep, ns, "MotorFanSpin",
|
|
GuidFor("MotorFanSpin"),
|
|
twin.ShaftAngle.NodeId, primPath + "/Motor/FanBlades", "xformOp:rotateZ", "double",
|
|
OpenUsdRenderTargetKindEnum.Rotation, ShaftRenderScale * FanToShaftRatio);
|
|
|
|
// BearingTemperature is published in Kelvin (OPC 40223), but the
|
|
// DisplayColor render target ramps blue -> red over the datasheet
|
|
// bearing-temperature range, so the binding declares the Kelvin shift.
|
|
CreateBinding(rep, ns, "BearingTempColor",
|
|
GuidFor("BearingTempColor"),
|
|
bearingTemp, primPath + "/Body", "primvars:displayColor", "color3f[]",
|
|
OpenUsdRenderTargetKindEnum.DisplayColor, 1.0,
|
|
offset: -KelvinOffset);
|
|
|
|
// Bearing-temperature gauge: 0 degrees at the bottom of the datasheet
|
|
// range, sweeping SweepDegrees over the whole range.
|
|
CreateBinding(rep, ns, "BearingTempNeedle",
|
|
GuidFor("BearingTempNeedle"),
|
|
bearingTemp, primPath + "/PowerEnd/TempGauge/Needle", "xformOp:rotateZ", "double",
|
|
OpenUsdRenderTargetKindEnum.Rotation,
|
|
GaugeSweepDegrees / (PumpDatasheet.Ranges.BearingTemperatureMax -
|
|
PumpDatasheet.Ranges.BearingTemperatureMin),
|
|
offset: -GaugeSweepDegrees * PumpDatasheet.Ranges.BearingTemperatureMin /
|
|
(PumpDatasheet.Ranges.BearingTemperatureMax -
|
|
PumpDatasheet.Ranges.BearingTemperatureMin));
|
|
|
|
// Discharge pressure gauge needle over the datasheet pressure range.
|
|
CreateBinding(rep, ns, "DischargePressureNeedle",
|
|
GuidFor("DischargePressureNeedle"),
|
|
diffPressure, primPath + "/Discharge/Gauge/Needle", "xformOp:rotateZ", "double",
|
|
OpenUsdRenderTargetKindEnum.Rotation,
|
|
GaugeSweepDegrees / PumpDatasheet.Ranges.DifferentialPressureMax);
|
|
|
|
// Suction vessel: the connector converts a scalar level into a scalar,
|
|
// and xformOp:translate needs a double3, so the simulation publishes the
|
|
// surface position as a structured coordinate the Translation profile
|
|
// accepts. The surface disc rides on top of the liquid.
|
|
twin.FluidSurface = CreatePumpVariable(
|
|
pump,
|
|
"FluidSurfacePosition",
|
|
Opc.Ua.DataTypeIds.ThreeDCartesianCoordinates,
|
|
Variant.From(new ExtensionObject(FluidSurfaceAt(
|
|
PumpDatasheet.Simulation.LevelNominal))),
|
|
writable: false);
|
|
twin.FluidSurface.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds;
|
|
CreateBinding(rep, ns, "SuctionLevelRise",
|
|
GuidFor("SuctionLevelRise"),
|
|
twin.FluidSurface.NodeId, primPath + "/SuctionVessel/Surface",
|
|
"xformOp:translate", "double3",
|
|
OpenUsdRenderTargetKindEnum.Translation, 1.0);
|
|
|
|
// Efficiency and mass flow carry no render semantics of their own -- the
|
|
// DisplayColor ramp models a temperature, so colouring efficiency with it
|
|
// would read as a lie. They are surfaced as attributes a viewer shows on
|
|
// selection, which is what makes the twin inspectable.
|
|
CreateBinding(rep, ns, "EfficiencyReadout",
|
|
GuidFor("EfficiencyReadout"),
|
|
efficiency, primPath + "/Motor/Nameplate", "inputs:pumpEfficiency", "double",
|
|
OpenUsdRenderTargetKindEnum.Custom, 1.0);
|
|
|
|
// Suction line tint follows the pumped fluid temperature. The
|
|
// DisplayColor ramp runs blue -> red over 20..100 degrees Celsius, so
|
|
// the binding declares the Kelvin shift and nothing else.
|
|
CreateBinding(rep, ns, "FluidTempColor",
|
|
GuidFor("FluidTempColor"),
|
|
fluidTemp, primPath + "/Suction/Neck/Mat/Surface", "inputs:diffuseColor", "color3f",
|
|
OpenUsdRenderTargetKindEnum.DisplayColor, 1.0,
|
|
offset: -KelvinOffset);
|
|
|
|
CreateBinding(rep, ns, "MassFlowReadout",
|
|
GuidFor("MassFlowReadout"),
|
|
massFlow, primPath + "/Motor/Nameplate", "inputs:massFlow", "double",
|
|
OpenUsdRenderTargetKindEnum.Custom, 1.0,
|
|
sourceSemanticId: MassFlowSemanticId);
|
|
CreateBinding(rep, ns, "NumberOfStartsReadout",
|
|
GuidFor("NumberOfStartsReadout"),
|
|
numberOfStarts, primPath + "/Motor/Nameplate", "inputs:numberOfStarts", "double",
|
|
OpenUsdRenderTargetKindEnum.Custom, 1.0);
|
|
|
|
AttachAlarmBindings(pump, rep, ns, primPath, twin);
|
|
|
|
// 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.
|
|
twin.SpeedSetpoint = CreatePumpVariable(
|
|
pump, "SpeedSetpoint", Opc.Ua.DataTypeIds.Double, new Variant(0.0), writable: true);
|
|
twin.SpeedSetpoint.OnReadUserAccessLevel = OnReadCommandTargetUserAccessLevel;
|
|
CreateBinding(rep, ns, "SpeedSetpointCommand",
|
|
GuidFor("SpeedSetpointCommand"),
|
|
NodeId.Null, primPath + "/Impeller", "inputs:speedSetpoint", "double",
|
|
kind: null, 1.0,
|
|
bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdCommandBindingType,
|
|
signalRole: OpenUsdSignalRoleEnum.Controllable,
|
|
commandTargetNodeId: twin.SpeedSetpoint.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 <Component> bindings on the pump representation.
|
|
AttachPumpComponents(pump, rep, ns, primPath);
|
|
|
|
SystemContext.AssignInstanceChildNodeIds(rep);
|
|
m_twins[pump.NodeId] = twin;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Binds the per-pump supervision states, so the twin distinguishes
|
|
/// <i>which</i> fault a pump has - and which pump has it - rather than
|
|
/// showing one undifferentiated halo.
|
|
/// </summary>
|
|
private void AttachAlarmBindings(
|
|
PumpState pump,
|
|
OpenUsdRepresentationState rep,
|
|
ushort ns,
|
|
string primPath,
|
|
PumpTwin twin)
|
|
{
|
|
SupervisionState? events = pump.Events;
|
|
NodeId cavitation = events?.SupervisionProcessFluid?.Cavitation?.NodeId ?? NodeId.Null;
|
|
NodeId motorOverheat = events?.SupervisionPumpOperation?.MotorOverheat?.NodeId ?? NodeId.Null;
|
|
|
|
// Any active supervision state draws a red circle on the floor around
|
|
// the machine. A ring reads from anywhere in the hall and from any
|
|
// camera angle; a lamp on a mast only reads when you happen to be
|
|
// looking straight at it.
|
|
CreateBinding(rep, ns, "AlarmRingVisibility",
|
|
GuidFor("AlarmRingVisibility"),
|
|
twin.AlarmActive!.NodeId, primPath + "/AlarmRing", "visibility", "token",
|
|
OpenUsdRenderTargetKindEnum.Visibility, 1.0,
|
|
bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType,
|
|
alarmAspect: OpenUsdAlarmAspectEnum.ActiveState);
|
|
|
|
// Which fault, at the place on the machine where it actually is: the
|
|
// ring says a pump is in alarm, these say why.
|
|
CreateBinding(rep, ns, "CavitationHalo",
|
|
GuidFor("CavitationHalo"),
|
|
cavitation, primPath + "/Suction/CavitationHalo", "visibility", "token",
|
|
OpenUsdRenderTargetKindEnum.Visibility, 1.0,
|
|
bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType,
|
|
alarmAspect: OpenUsdAlarmAspectEnum.ActiveState);
|
|
CreateBinding(rep, ns, "OverheatHalo",
|
|
GuidFor("OverheatHalo"),
|
|
motorOverheat, primPath + "/OverheatHalo", "visibility", "token",
|
|
OpenUsdRenderTargetKindEnum.Visibility, 1.0,
|
|
bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType,
|
|
alarmAspect: OpenUsdAlarmAspectEnum.ActiveState);
|
|
|
|
// The OverTempAlarm condition itself is deliberately not bound. The
|
|
// fluent alarm builder leaves the condition's state children on their
|
|
// standard namespace-0 declaration NodeIds - which
|
|
// PumpInstanceNodeIdRegressionTests pins - so every pump's alarm shares
|
|
// one ActiveState, Severity and AckedState node. Binding those would
|
|
// ring every pump in the hall at once. The per-pump supervision states
|
|
// above are the alarm indication instead: they are genuinely per
|
|
// instance, and they are what drives the condition through
|
|
// ActivatesAlarm in the first place.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Position of the suction-vessel liquid surface for a published level.
|
|
/// </summary>
|
|
private static ThreeDCartesianCoordinates FluidSurfaceAt(double levelMetres)
|
|
{
|
|
return new ThreeDCartesianCoordinates
|
|
{
|
|
X = 0.0,
|
|
Y = 0.0,
|
|
Z = levelMetres * FluidSurfaceScale
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stable declaration identifiers for the pump bindings. The binding model
|
|
/// defines the effective runtime identity as
|
|
/// (represented object, BindingDefinitionId) and the id itself as a
|
|
/// declaration identifier, "NOT a runtime instance key" — so every pump
|
|
/// declares the same ids and its own representation disambiguates them.
|
|
/// </summary>
|
|
private static readonly Dictionary<string, Guid> s_bindingDefinitionIds =
|
|
new(StringComparer.Ordinal)
|
|
{
|
|
["ShaftSpin"] = new Guid("6e63cf2c-f2de-4f78-a8f8-f0ccdbb7647a"),
|
|
["BearingTempColor"] = new Guid("b1a1f6f0-5c2b-5a1e-9f3a-2b7c4d8e0011"),
|
|
["AlarmRingVisibility"] = new Guid("d3c3b8f2-7e4d-5c30-b15c-4d9e6a0b2233"),
|
|
["SpeedSetpointCommand"] = new Guid("e4d4c9a3-8f5e-5d41-c26d-5e0f7b1c3344"),
|
|
["ImpellerComponent"] = new Guid("a1b2c3d4-0001-4000-8000-000000000001"),
|
|
["BearingComponent"] = new Guid("a1b2c3d4-0001-4000-8000-000000000002"),
|
|
["BayLayout"] = new Guid("a1b2c3d4-0005-4000-8000-000000000001"),
|
|
["MotorFanSpin"] = new Guid("a1b2c3d4-0005-4000-8000-000000000002"),
|
|
["BearingTempNeedle"] = new Guid("a1b2c3d4-0005-4000-8000-000000000003"),
|
|
["DischargePressureNeedle"] = new Guid("a1b2c3d4-0005-4000-8000-000000000004"),
|
|
["SuctionLevelRise"] = new Guid("a1b2c3d4-0005-4000-8000-000000000005"),
|
|
["EfficiencyReadout"] = new Guid("a1b2c3d4-0005-4000-8000-000000000006"),
|
|
["FluidTempColor"] = new Guid("a1b2c3d4-0005-4000-8000-000000000007"),
|
|
["MassFlowReadout"] = new Guid("a1b2c3d4-0005-4000-8000-000000000008"),
|
|
["NumberOfStartsReadout"] = new Guid("a1b2c3d4-0005-4000-8000-000000000009"),
|
|
["CavitationHalo"] = new Guid("a1b2c3d4-0005-4000-8000-00000000000a"),
|
|
["OverheatHalo"] = new Guid("a1b2c3d4-0005-4000-8000-00000000000b")
|
|
};
|
|
|
|
/// <summary>
|
|
/// The declaration identifier of the named binding.
|
|
/// </summary>
|
|
private static Guid GuidFor(string binding)
|
|
{
|
|
return s_bindingDefinitionIds[binding];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the integrated shaft angle at a legible speed: a real 2900 rpm
|
|
/// shaft aliases into a stroboscopic blur at any practical sampling rate.
|
|
/// </summary>
|
|
private const double ShaftRenderScale = 0.0025;
|
|
|
|
/// <summary>
|
|
/// The cooling fan sits on the same shaft, so it turns at the same speed;
|
|
/// rendering it slightly faster keeps the two visually distinguishable.
|
|
/// </summary>
|
|
private const double FanToShaftRatio = 1.6;
|
|
|
|
/// <summary>
|
|
/// Angular sweep of a gauge needle across its full scale, in degrees.
|
|
/// </summary>
|
|
private const double GaugeSweepDegrees = 270.0;
|
|
|
|
/// <summary>
|
|
/// Kelvin-to-Celsius shift the colour render targets expect.
|
|
/// </summary>
|
|
private const double KelvinOffset = 273.15;
|
|
|
|
/// <summary>
|
|
/// Metres of modelled vessel height per metre of published level. The
|
|
/// suction vessel is drawn at a fifth of its real height so it does not
|
|
/// tower over the machine it feeds.
|
|
/// </summary>
|
|
private const double FluidSurfaceScale = 0.2;
|
|
|
|
// 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";
|
|
|
|
/// <summary>
|
|
/// §5.10/§9: the write right a command target requires is <b>withheld by
|
|
/// default</b> — an anonymous session sees a read-only UserAccessLevel and its
|
|
/// write is rejected. Only a session that authenticated (and therefore holds a
|
|
/// Role beyond <c>Anonymous</c>) sees CurrentWrite.
|
|
/// </summary>
|
|
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<NodeId> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers every pump's representation in the well-known
|
|
/// <c>Server/OpenUSD/Representations</c> registry. A connector discovers
|
|
/// twins through that registry alone, so a representation that is not
|
|
/// organised there is invisible no matter how completely it is authored.
|
|
/// </summary>
|
|
private void OrganiseRepresentations()
|
|
{
|
|
FolderState? registry = m_openUsdRoot?.Representations;
|
|
if (registry == null)
|
|
{
|
|
return;
|
|
}
|
|
foreach (PumpTwin twin in m_twins.Values)
|
|
{
|
|
OpenUsdRepresentationState? rep = twin.Representation;
|
|
if (rep == null)
|
|
{
|
|
continue;
|
|
}
|
|
registry.AddReference(ReferenceTypeIds.Organizes, false, rep.NodeId);
|
|
rep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|