mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 05:57:47 +08:00
# Description Adds a **Generators companion-specification sample** alongside the existing pump sample, and a **`SiteCompositionServer`** that composes several device servers into one OpenUSD scene at a supervisory level — demonstrating that servers can be aggregated without mirroring their address spaces. Three things, in dependency order: ### 1. `GeneratorServer` A self-contained server realising the draft Generators spec (`http://opcfoundation.org/UA/Generators/`) end to end: N simulated generating sets, DI + Machinery integration, a twelve-state operating state machine, four protection alarms per set, six control methods, and a per-set OpenUSD twin. The organising idea is that **load fraction is the only independent variable**. Everything else is a function of it: ``` V̇_f(x) = 3.67 + 100·x fuel rate [L/h] η(x) = P(x) / (V̇_f(x) · ρ · LHV) efficiency S = P / PF I = S / (√3·V_LL) f = N·p / 120 ``` This is not stylistic. When each measurement is an independent oscillator — as the pump sample's simulation once was — a server happily publishes a duty point no real machine could occupy, and no test can catch it because there is nothing for the values to be inconsistent *with*. Deriving them from one variable means `P = √3·V·I·PF` and `η = P/(V̇·ρ·LHV)` hold at every tick by construction. The published `DATASHEET.md`, the engineering ranges, the trip points and the simulation all read the same constants in `GeneratorDatasheet.cs`, so the document and the server cannot describe different machines. The machine is a fictitious **SimGen Systems GenX-500** (400 kW prime / 440 kW standby, 400/230 V, 50 Hz, four-pole). Figures are representative of a real industrial genset class; no vendor is named anywhere in the code or docs. ### 2. Cross-server composition in the connector `RemoteSessionFactory` already existed in the client library but the connector CLI never set it, so cross-server composition was unreachable. `--federate` wires it up: the connector opens a session to each server named by a component binding, discovers its representations and drives its bindings into the same stage. It is **opt-in** because the endpoint the connector dials comes from the server being rendered rather than from the operator, which makes honouring it a trust decision. Federation is best-effort per component — a subordinate that is down is logged and skipped, and the rest of the scene still renders — which is the only `src/` change in this PR. ### 3. `SiteCompositionServer` Owns no devices. It publishes a site stage plus one cross-server component binding per subordinate, carrying that server's `ComponentServerUri` and `ComponentEndpointUrl`. Nothing is mirrored, so there is no cache to invalidate and no second copy of the truth. ## Notes for reviewers **Everything interesting in this PR was found by running the servers, not by reading the code.** Six defects, none of which looked wrong in review: - `IsShutdown` and `SubsystemName` are *optional* on `GeneratorProtectionAlarmType`, so the generated factory does not materialise them. `CreateOrReplace` alone produces a child that exists, appears in `GetChildren` and holds the right value — but carries **no `ReferenceTypeId`**, so no browse can reach it. Every alarm was publishing its trip without saying whether the trip stops the machine or which subsystem to go to. `AddXxx(context)` first is what gives the child its `HasProperty` reference. - **The protections could never fire.** Every trip point sits outside the band the datasheet curves produce — that is what a datasheet *means* — so all four alarms, the shutdown class, `ResetFaults` and the whole `Fault` branch of the state machine were unreachable while the README documented them as observable. Overload was worse: the load clamp ceiling was set to exactly the trip point, making a strictly-greater-than comparison unsatisfiable. Faults are now injectable, and the first set — the one the hero camera frames — develops one on a slow rotation. - **A shutdown trip removes the condition that caused it**, because oil pressure and coolant temperature are only supervised while the engine turns. Each alarm went active for one tick and cleared, leaving an operator with a stopped machine and no indication of why. Shutdown-class alarms now latch until the set leaves the shutdown state. Every unit test that checked the *condition* passed throughout — the defect only appears when the trip and the supervision interact over time. - **`Synchronizing` and `Paralleled` were declared, drawn in the README and never entered.** The first set now energises a dead bus and closes onto it while every other set synchronises to a live one — both how a real plant parallels, and what makes the two states observable. - **The tick raced client method calls.** The simulation tick runs on a thread-pool thread while method calls arrive on request threads, and both transition sets and write the same nodes. A tick moving a set to `Cooldown` and a concurrent `EmergencyStop` could interleave the paired `CurrentState` / `CurrentState.Id` writes and leave a client with a state *name* from one transition and a state *node* from the other — the exact failure the paired write exists to prevent, reintroduced by the threading model. Both paths now take one gate. - Low oil pressure supervised from raw speed **tripped every set during cranking**, because pressure has not built yet. Now gated on `IsSpinning`, which is what a real start-up bypass does. Smaller fixes: shutdown trips are applied after the whole evaluation pass (stopping mid-loop made the remaining conditions read healthy, collapsing simultaneous trips to whichever came first in the table); `ResetFaults` reports its clears as events (a client learns of condition state changes only through events, so a silent clear leaves an alarm-list client showing it forever); the start counter moved into the transition so commanded starts are counted; the federated connector closes the remote session if the connector constructor throws between taking ownership and registering it; and `prepare_machinery_nodeset.py` now *asserts* the IA namespace is last before removing it rather than only claiming so in its docstring — removing any earlier entry would silently renumber every namespace after it and rebind their NodeIds. **A model limitation is left visible rather than papered over.** `GeneratorStateMachineType` declares an emergency stop only out of `Running`, `Loaded` and `Paralleled`, so the sample refuses `EmergencyStop` from `Starting` and `Warmup`. A real panel stops from anywhere. That is the specification's shape, not this sample's choice, so it is pinned by a test and named in the docs. **`Model/` vendors a reduced Machinery nodeset**, derived mechanically by whitelist. The full official nodeset does not survive the model source generator (`MODELGEN003`) and drags in IA through a single optional `Stacklight` member a generating set does not have. Deriving it by script keeps the provenance checkable; the whitelist is the only thing to edit when more types are needed. **Never hand-edit the generated output.** **The federated scene renders end to end.** Getting there surfaced three more defects, all of which produced a plausible-looking scene with nothing in it: the generator sample's plant aggregation was created under an already-registered `DeviceSet` and so was invisible to every client (it had *never* rendered geometry, standalone or federated); the connector's asset fetch ran on the primary session only, so composition referenced layers that were never downloaded; and both generator colour bindings wrote `primvars:displayColor` to prims that did not declare it, so the renderer rejected every update while the file still looked correct. Cross-server components compose under the subordinate's own root, so the site layer now *places* `/Plant` and `/Powerhouse` rather than leaving them stacked on the origin. **`OpenUsdRepresentation` is now mounted with `HasAddIn`, not `HasComponent`** (review feedback). The nodeset says so explicitly — *"Mounted with HasAddIn"* — but every server in the tree used plain `HasComponent`. It survived because `HasAddIn` is a *subtype* of `HasComponent`: the representation still browses, still aggregates and still drives a twin, so every functional test passed before the fix and after it, and only a conformance checker could tell. Seven mount sites corrected. `PumpDeviceIntegrationServer` and `MinimalRobotServer` had hand-rolled the mount instead of calling the shared `CreateRepresentation` helper, which is exactly how they drifted from it. Both sample E2E suites now assert the reference type across **every** discovered representation — a narrower check would have missed the plant-level representation that arrived later from master still carrying `HasComponent`. **Live colour is not achievable in the current OpenUSD viewer, and that is now documented rather than worked around.** Proven with a standalone probe: `primvars:displayColor` resolves as `color3f[]` from the `UsdGeomGprim` schema whatever the layer declares, and the `OpenUsd 0.4.0-alpha` managed API has no writer for that type. Bound `UsdPreviewSurface` materials are not shaded either, so geometry renders grey unless it also carries an explicit `displayColor` primvar. The **visibility** bindings carry no such caveat and are what the samples rely on to show state. Filed upstream as [openusd-dotnet#2](https://github.com/marcschier/openusd-dotnet/issues/2), [#3](https://github.com/marcschier/openusd-dotnet/issues/3) and [#4](https://github.com/marcschier/openusd-dotnet/issues/4), and linked from `docs/OpenUsd.md` so a reader can check whether they have since been fixed. ## Related Issues No tracking issue — this is additive sample and documentation work with a single small, self-contained change to `src/` (per-component error isolation and session-ownership hardening in `OpenUsdConnector.Composition.cs`). Happy to open one if maintainers would prefer it tracked. ## 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. - [x] 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. ### Testing The generator fixtures live in `tests/Opc.Ua.OpenUsd.Tests/Generator` — **82 tests**, no new project. `Opc.Ua.Di.Tests` would have been the natural home next to the pump fixtures, but both device samples source-generate their own reduced `Opc.Ua.Machinery` model and expose it via `InternalsVisibleTo`, so referencing both from one assembly makes every Machinery type ambiguous (CS0433); `Opc.Ua.OpenUsd.Tests` references no other sample and already hosts `RobotAssetContractTests`. The suite runs **870** on net10.0 and net48 (784 existing + 86) and **788** on net472, where the sample does not exist and only the framework-independent asset-contract tests run — forgetting exactly that exclusion broke CI on the sibling pump PR. The tests target the things in this sample that fail *quietly*: - **Datasheet conformance** holds the model to its own claim — rated speed is `120·f/p`, rated current follows from the rating, the fuel curve reproduces the published table, and `η = P/(V̇·ρ·LHV)` reconciles at every load. Efficiency is swept across the whole simulated range to show it stays inside (0,100) and rises monotonically; a fit that goes negative somewhere in its range is a coincidence that happens to look right at the duty point, not a model. A further test reads `DATASHEET.md` and checks the document quotes the figures the server actually serves. - **Drift between the two descriptions of the state machine** — the physics' `IsLegalTransition` and the model's `GeneratorStateMap` — is checked in *both* directions. A transition the physics permits but the map lacks moves a machine without telling a client; one the map holds but the physics refuses is dead weight that looks supported. - **Reachability at run time**, not just in the declared table — the test that catches states the model declares and nothing ever enters. - **That every protection can actually fire**, which is the half of the alarm contract that "a healthy set annunciates nothing" does not cover. A **hosted end-to-end fixture** connects a real client and asserts what the address space actually exposes — the aggregation at `/Powerhouse/Generators`, its component binding, and every per-set twin. That is the only kind of test that catches the registration defect above: the node object had the right browse name, binding and asset reference, so every test that inspected objects passed while no client could see it. **Every state-machine and protection fix was checked by mutation.** Restoring the clamp ceiling, the direct `Running → Loaded` path, the old start-counter placement, or removing the alarm opt-in each makes the corresponding test fail, naming the specific defect. A test that has never been seen to fail has not been shown to work. Verified against a running plant: `Synchronizing`, `Paralleled` and `Fault` all observed on a two-set server, with `HighCoolantTemperatureAlarm` seen active on the fault subject. Method behaviour verified separately — per-set distinct `CurrentState` nodes, `EmergencyStop` → `EmergencyStopped` with `Start` then refused as `BadInvalidState`, `ResetFaults` → `Off`, `SetOperatingMode(9999)` refused with the mode unchanged, and the other sets untouched throughout. Also verified as a three-server federated stage. 0 warnings on a clean Release rebuild of both samples, the client library, the connector and the tests, across every TFM. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
519 lines
22 KiB
C#
519 lines
22 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.Generic;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using Opc.Ua;
|
|
using Opc.Ua.OpenUsd;
|
|
using Opc.Ua.OpenUsd.Server;
|
|
using Opc.Ua.Server;
|
|
using Opc.Ua.Server.Fluent;
|
|
using Opc.Ua.Server.NodeManager;
|
|
|
|
namespace SiteComposition
|
|
{
|
|
/// <summary>
|
|
/// Runtime options for the site composition sample.
|
|
/// </summary>
|
|
public sealed class SiteCompositionOptions
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the endpoint of the server that owns the pumps.
|
|
/// </summary>
|
|
public string? PumpServerEndpointUrl { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the endpoint of the server that owns the generator sets.
|
|
/// </summary>
|
|
public string? GeneratorServerEndpointUrl { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// A supervisory node manager that owns no devices and composes the machines of
|
|
/// other servers into a single scene.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This is the SCADA-level counterpart to a device server. It publishes a site
|
|
/// stage - ground, buildings, lighting and a control-room camera - and declares
|
|
/// one cross-server component binding per subordinate server. A connector run
|
|
/// with federation enabled opens a session to each of those servers, discovers
|
|
/// their representations and drives their bindings into the same stage, so one
|
|
/// scene shows machines owned by three different servers, live.
|
|
/// </para>
|
|
/// <para>
|
|
/// Nothing is mirrored. The site server never proxies a subordinate's address
|
|
/// space; it only says where the machines are and lets the connector talk to
|
|
/// each owner directly, so there is no cache to invalidate and no second copy of
|
|
/// the truth.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed partial class SiteNodeManager : FluentNodeManagerBase
|
|
{
|
|
private const string SiteRootLayerIdentifier = "asset-repo/Site.usd";
|
|
private const string SitePrimPath = "/Site";
|
|
private const string PumpHallPrimPath = "/Site/PumpHall";
|
|
private const string PowerhousePrimPath = "/Site/Powerhouse";
|
|
|
|
private static readonly Guid s_pumpComponentId =
|
|
new("b41d7e20-0001-4f52-9a63-1c7d8e0b5a01");
|
|
|
|
private static readonly Guid s_generatorComponentId =
|
|
new("b41d7e20-0002-4f52-9a63-1c7d8e0b5a02");
|
|
|
|
private readonly SiteCompositionOptions m_options;
|
|
private readonly ILogger m_log;
|
|
private OpenUsdRootState? m_openUsdRoot;
|
|
private OpenUsdStageState? m_siteStage;
|
|
private FolderState? m_areas;
|
|
|
|
/// <summary>
|
|
/// Initialises a new <see cref="SiteNodeManager"/>.
|
|
/// </summary>
|
|
/// <param name="server">The server the manager belongs to.</param>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
/// <param name="options">Sample options naming the subordinate servers.</param>
|
|
public SiteNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
IOptions<SiteCompositionOptions>? options = null)
|
|
: base(server, configuration, Namespaces.Site, Opc.Ua.OpenUsd.Namespaces.OpenUSD)
|
|
{
|
|
SystemContext.NodeIdFactory = this;
|
|
m_options = options?.Value ?? new SiteCompositionOptions();
|
|
m_log = server.Telemetry.CreateLogger<SiteNodeManager>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the namespace index this sample's instances live in.
|
|
/// </summary>
|
|
private ushort SiteNamespaceIndex => NamespaceIndexes[0];
|
|
|
|
/// <inheritdoc/>
|
|
public override NodeId New(ISystemContext context, NodeState node) {
|
|
if (node is BaseInstanceState { Parent: not null } instance)
|
|
{
|
|
return new NodeId(
|
|
$"{instance.Parent.NodeId.IdentifierAsString}_{instance.SymbolicName}",
|
|
SiteNamespaceIndex);
|
|
}
|
|
return node.NodeId;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override ValueTask<NodeStateCollection> LoadPredefinedNodesAsync(
|
|
ISystemContext context,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var nodes = new NodeStateCollection();
|
|
nodes.AddOpcUaOpenUsd(context);
|
|
return new ValueTask<NodeStateCollection>(nodes);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override async ValueTask CreateAddressSpaceAsync(
|
|
IDictionary<NodeId, IList<IReference>> externalReferences,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
await base.CreateAddressSpaceAsync(externalReferences, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await MaterialiseSiteTopologyAsync(cancellationToken).ConfigureAwait(false);
|
|
await MaterialiseOpenUsdFacilityAsync(cancellationToken).ConfigureAwait(false);
|
|
await MaterialiseCrossServerCompositionAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
LinkOpenUsdRootToServer(externalReferences);
|
|
LinkAreasToObjectsFolder(externalReferences);
|
|
|
|
m_log.SiteAddressSpaceReady(
|
|
m_options.PumpServerEndpointUrl ?? "(none)",
|
|
m_options.GeneratorServerEndpointUrl ?? "(none)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a small browsable site hierarchy, so the supervisory server is a
|
|
/// real server rather than a bare stage host.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
private async ValueTask MaterialiseSiteTopologyAsync(CancellationToken cancellationToken)
|
|
{
|
|
var areas = new FolderState(null)
|
|
{
|
|
SymbolicName = "Site",
|
|
BrowseName = new QualifiedName("Site", SiteNamespaceIndex),
|
|
DisplayName = new LocalizedText("Site"),
|
|
TypeDefinitionId = Opc.Ua.ObjectTypeIds.FolderType,
|
|
NodeId = new NodeId("Site", SiteNamespaceIndex),
|
|
EventNotifier = EventNotifiers.None,
|
|
};
|
|
|
|
AddArea(areas, "PumpHall", "Pump Hall", m_options.PumpServerEndpointUrl);
|
|
AddArea(areas, "Powerhouse", "Powerhouse", m_options.GeneratorServerEndpointUrl);
|
|
|
|
SystemContext.AssignInstanceChildNodeIds(areas);
|
|
await AddPredefinedNodeAsync(SystemContext, areas, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
m_areas = areas;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds one area object recording which server owns the machines in it.
|
|
/// </summary>
|
|
/// <param name="parent">The site folder.</param>
|
|
/// <param name="name">Area browse name.</param>
|
|
/// <param name="displayName">Area display name.</param>
|
|
/// <param name="endpointUrl">Endpoint of the server that owns the area.</param>
|
|
private void AddArea(FolderState parent, string name, string displayName, string? endpointUrl)
|
|
{
|
|
var area = new BaseObjectState(parent)
|
|
{
|
|
SymbolicName = name,
|
|
BrowseName = new QualifiedName(name, SiteNamespaceIndex),
|
|
DisplayName = new LocalizedText(displayName),
|
|
TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType,
|
|
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
|
};
|
|
parent.AddChild(area);
|
|
|
|
// The generic variable states are abstract in this stack, so the concrete
|
|
// carrier is the non-generic one with a Variant value.
|
|
var source = new BaseDataVariableState(area)
|
|
{
|
|
SymbolicName = "SourceServer",
|
|
BrowseName = new QualifiedName("SourceServer", SiteNamespaceIndex),
|
|
DisplayName = new LocalizedText("SourceServer"),
|
|
TypeDefinitionId = Opc.Ua.VariableTypeIds.PropertyType,
|
|
ReferenceTypeId = ReferenceTypeIds.HasProperty,
|
|
DataType = Opc.Ua.Types.DataTypeIds.String,
|
|
ValueRank = ValueRanks.Scalar,
|
|
AccessLevel = AccessLevels.CurrentRead,
|
|
UserAccessLevel = AccessLevels.CurrentRead,
|
|
Value = Variant.From(endpointUrl ?? string.Empty),
|
|
};
|
|
area.AddChild(source); }
|
|
|
|
/// <summary>
|
|
/// Declares one cross-server component binding per subordinate server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The binding carries the endpoint of the server that owns the machines.
|
|
/// A connector without federation still composes the placeholder prim and
|
|
/// renders the site shell; with federation it opens a session to each named
|
|
/// server and brings that server's machines in live. Failing closed on the
|
|
/// client rather than the server is deliberate - the site server can
|
|
/// advertise where the machines are without dictating that anyone connect.
|
|
/// </remarks>
|
|
private async ValueTask MaterialiseCrossServerCompositionAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (m_siteStage == null || m_areas == null)
|
|
{
|
|
return;
|
|
}
|
|
ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD);
|
|
|
|
OpenUsdRepresentationState site = SystemContext.CreateRepresentation(
|
|
m_areas, m_siteStage.NodeId, SitePrimPath, ns);
|
|
|
|
AddSubordinate(site, ns, "PumpHall", s_pumpComponentId, PumpHallPrimPath,
|
|
m_options.PumpServerEndpointUrl);
|
|
AddSubordinate(site, ns, "Powerhouse", s_generatorComponentId, PowerhousePrimPath,
|
|
m_options.GeneratorServerEndpointUrl);
|
|
|
|
if (m_openUsdRoot?.Representations is FolderState registry)
|
|
{
|
|
site.RegisterInDiscovery(registry);
|
|
}
|
|
|
|
// The representation is built after its owner was registered, so it has
|
|
// to register its own subtree - otherwise it browses from nowhere and a
|
|
// connector reports that the server publishes no representation at all.
|
|
SystemContext.AssignInstanceChildNodeIds(site);
|
|
await AddPredefinedNodeAsync(SystemContext, site, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Declares one subordinate server as a component of the site.
|
|
/// </summary>
|
|
/// <param name="site">The site representation.</param>
|
|
/// <param name="ns">OpenUSD namespace index.</param>
|
|
/// <param name="name">Binding name.</param>
|
|
/// <param name="definitionId">Stable binding definition id.</param>
|
|
/// <param name="primPath">Scope the subordinate's machines land in.</param>
|
|
/// <param name="endpointUrl">Endpoint of the owning server.</param>
|
|
private void AddSubordinate(
|
|
OpenUsdRepresentationState site,
|
|
ushort ns,
|
|
string name,
|
|
Guid definitionId,
|
|
string primPath,
|
|
string? endpointUrl)
|
|
{
|
|
if (string.IsNullOrEmpty(endpointUrl))
|
|
{
|
|
return;
|
|
}
|
|
site.AddComponentBinding(
|
|
SystemContext,
|
|
ns,
|
|
name,
|
|
definitionId,
|
|
OpenUsdCardinalityEnum.Many,
|
|
OpenUsdCompositionArcEnum.Reference,
|
|
primPath,
|
|
componentRepresentation: default,
|
|
assetReference: null,
|
|
dynamic: false,
|
|
changeEventSource: default,
|
|
componentServerUri: endpointUrl,
|
|
componentEndpointUrl: endpointUrl);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the well-known OpenUSD facility and the site stage.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
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_siteStage = SystemContext.CreateInstanceOfOpenUsdStageType(
|
|
stages, new QualifiedName("SiteStage", ns));
|
|
stages.AddChild(m_siteStage);
|
|
m_siteStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!)
|
|
.Value = SiteRootLayerIdentifier;
|
|
|
|
List<ServedAsset> served = LoadServedAssets();
|
|
byte[] rootLayerBytes = served
|
|
.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_siteStage.CreateOrReplaceRootLayerDigest(SystemContext, null!)
|
|
.Value = (ByteString)digest;
|
|
m_siteStage.CreateOrReplaceRootLayerDigestAlgorithm(SystemContext, null!)
|
|
.Value = OpenUsdDigestAlgorithmEnum.Sha256;
|
|
|
|
root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server);
|
|
UsdAssetDelivery.AttachStageAssets(SystemContext, m_siteStage, ns, served);
|
|
|
|
SystemContext.AssignInstanceChildNodeIds(root);
|
|
await AddPredefinedNodeAsync(SystemContext, root, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
m_openUsdRoot = root;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
m_siteStage = null;
|
|
m_openUsdRoot = null;
|
|
m_log.OpenUsdFacilityFailed(ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the site shell layer this server serves.
|
|
/// </summary>
|
|
/// <returns>The served asset closure.</returns>
|
|
/// <remarks>
|
|
/// Only the site shell. The machine geometry belongs to the subordinate
|
|
/// servers, which serve their own component assets to the same connector.
|
|
/// </remarks>
|
|
private static List<ServedAsset> LoadServedAssets()
|
|
{
|
|
return
|
|
[
|
|
new ServedAsset("Site.usda", OpenUsdAssetKindEnum.RootLayer, ReadEmbeddedAsset("Site.usda")),
|
|
];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads one embedded USD layer.
|
|
/// </summary>
|
|
/// <param name="resourceName">Logical resource name.</param>
|
|
/// <returns>The layer bytes, or an empty array when absent.</returns>
|
|
private static byte[] ReadEmbeddedAsset(string resourceName)
|
|
{
|
|
using Stream? s = typeof(SiteNodeManager).Assembly.GetManifestResourceStream(resourceName);
|
|
if (s == null)
|
|
{
|
|
return [];
|
|
}
|
|
using var ms = new MemoryStream();
|
|
s.CopyTo(ms);
|
|
return ms.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Makes the OpenUSD facility browsable from the Server Object.
|
|
/// </summary>
|
|
/// <param name="externalReferences">The shared reference dictionary.</param>
|
|
private void LinkOpenUsdRootToServer(IDictionary<NodeId, IList<IReference>> externalReferences)
|
|
{
|
|
if (m_openUsdRoot == null)
|
|
{
|
|
return;
|
|
}
|
|
if (!externalReferences.TryGetValue(Opc.Ua.ObjectIds.Server, out IList<IReference>? refs))
|
|
{
|
|
refs = new List<IReference>();
|
|
externalReferences[Opc.Ua.ObjectIds.Server] = refs;
|
|
}
|
|
refs.Add(new NodeStateReference(
|
|
ReferenceTypeIds.HasComponent, false, m_openUsdRoot.NodeId));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Organises the site folder under Objects so a client can browse to it.
|
|
/// </summary>
|
|
/// <param name="externalReferences">The shared reference dictionary.</param>
|
|
private void LinkAreasToObjectsFolder(IDictionary<NodeId, IList<IReference>> externalReferences)
|
|
{
|
|
if (m_areas == null)
|
|
{
|
|
return;
|
|
}
|
|
if (!externalReferences.TryGetValue(Opc.Ua.ObjectIds.ObjectsFolder, out IList<IReference>? refs))
|
|
{
|
|
refs = new List<IReference>();
|
|
externalReferences[Opc.Ua.ObjectIds.ObjectsFolder] = refs;
|
|
}
|
|
refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, m_areas.NodeId));
|
|
m_areas.AddReference(ReferenceTypeIds.Organizes, true, Opc.Ua.ObjectIds.ObjectsFolder);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Namespaces this sample owns.
|
|
/// </summary>
|
|
internal static class Namespaces
|
|
{
|
|
/// <summary>
|
|
/// The site composition sample instance namespace.
|
|
/// </summary>
|
|
public const string Site = "http://opcfoundation.org/UA/Samples/SiteComposition/";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the <see cref="SiteNodeManager"/> for the DI hosting pipeline.
|
|
/// </summary>
|
|
public sealed class SiteNodeManagerFactory : IAsyncNodeManagerFactory
|
|
{
|
|
private readonly IOptions<SiteCompositionOptions>? m_options;
|
|
|
|
/// <summary>
|
|
/// Initialises a factory without options.
|
|
/// </summary>
|
|
public SiteNodeManagerFactory()
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialises a factory with sample options.
|
|
/// </summary>
|
|
/// <param name="options">Sample options.</param>
|
|
public SiteNodeManagerFactory(IOptions<SiteCompositionOptions>? options)
|
|
{
|
|
m_options = options;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public ArrayOf<string> NamespacesUris => new string[]
|
|
{
|
|
Namespaces.Site,
|
|
Opc.Ua.OpenUsd.Namespaces.OpenUSD,
|
|
}.ToArrayOf();
|
|
|
|
/// <inheritdoc/>
|
|
public ValueTask<IAsyncNodeManager> CreateAsync(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
#pragma warning disable CA2000 // ownership transferred to server
|
|
IAsyncNodeManager nodeManager = new SiteNodeManager(server, configuration, m_options);
|
|
#pragma warning restore CA2000
|
|
return new ValueTask<IAsyncNodeManager>(nodeManager);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Event id offsets for the site composition server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each per-file <c><ClassName>Log</c> class allocates its event ids relative
|
|
/// to the offset constant below, using <c>offset + <zero-based message index></c>,
|
|
/// so two files cannot silently claim the same id.
|
|
/// </remarks>
|
|
internal static class SiteCompositionServerEventIds
|
|
{
|
|
/// <summary>
|
|
/// Offset for messages raised by <see cref="SiteNodeManager"/>.
|
|
/// </summary>
|
|
public const int SiteNodeManager = 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Source-generated log messages for <see cref="SiteNodeManager"/>.
|
|
/// </summary>
|
|
internal static partial class SiteNodeManagerLog
|
|
{
|
|
[LoggerMessage(
|
|
EventId = SiteCompositionServerEventIds.SiteNodeManager + 0,
|
|
Level = LogLevel.Information,
|
|
Message = "Site composition ready. Pump server: {PumpServer}. Generator server: {GeneratorServer}.")]
|
|
public static partial void SiteAddressSpaceReady(
|
|
this ILogger logger, string pumpServer, string generatorServer);
|
|
|
|
[LoggerMessage(
|
|
EventId = SiteCompositionServerEventIds.SiteNodeManager + 1,
|
|
Level = LogLevel.Error,
|
|
Message = "Failed to materialise the OpenUSD facility.")]
|
|
public static partial void OpenUsdFacilityFailed(this ILogger logger, Exception exception);
|
|
}
|
|
}
|