mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-24 11:01:55 +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
588 lines
23 KiB
C#
588 lines
23 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.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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
internal sealed class FakeAddressSpace
|
|
{
|
|
/// <summary>
|
|
/// One reference held by the fake address space.
|
|
/// </summary>
|
|
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<FakeReference> m_references = [];
|
|
private readonly Dictionary<NodeId, DataValue> m_values = [];
|
|
private readonly Dictionary<NodeId, DataValue> m_accessLevels = [];
|
|
private readonly Dictionary<NodeId, DataValue> m_rolePermissions = [];
|
|
private readonly Dictionary<NodeId, DataValue> m_executables = [];
|
|
private readonly Dictionary<NodeId, byte[]> m_fileContents = [];
|
|
private readonly Dictionary<NodeId, (NodeId File, string Name)> m_fileMethods = [];
|
|
private readonly Dictionary<uint, (NodeId File, int Offset)> m_openHandles = [];
|
|
private int m_nextIdentifier = 5000;
|
|
private int m_nextHandle;
|
|
|
|
/// <summary>
|
|
/// The namespace table shared with the fake session.
|
|
/// </summary>
|
|
public NamespaceTable NamespaceUris { get; } = new();
|
|
|
|
/// <summary>
|
|
/// The namespace index the OpenUSD companion specification occupies.
|
|
/// </summary>
|
|
public ushort OpenUsdNamespaceIndex { get; }
|
|
|
|
/// <summary>
|
|
/// Number of Browse service invocations served so far.
|
|
/// </summary>
|
|
public int BrowseCount { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Number of Read service invocations served so far.
|
|
/// </summary>
|
|
public int ReadCount { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Creates an address space whose namespace table carries the OpenUSD namespace.
|
|
/// </summary>
|
|
public FakeAddressSpace()
|
|
{
|
|
OpenUsdNamespaceIndex = (ushort)NamespaceUris.GetIndexOrAppend(OpenUsdModel.NamespaceUri);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Allocates a fresh numeric NodeId in namespace zero.
|
|
/// </summary>
|
|
public NodeId NewNodeId()
|
|
{
|
|
return new NodeId((uint)Interlocked.Increment(ref m_nextIdentifier));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an Object child reachable through the supplied hierarchical reference type.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a Variable child carrying the supplied value.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a Method child.
|
|
/// </summary>
|
|
public NodeId AddMethod(NodeId parent, string browseName)
|
|
{
|
|
return Add(parent, browseName, NodeClass.Method, NodeId.Null,
|
|
ReferenceTypeIds.HasComponent, 0, default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a non-hierarchical reference (used for HasDictionaryEntry semantics).
|
|
/// </summary>
|
|
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
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// When set, the fake Open method returns these output arguments instead of a
|
|
/// file handle.
|
|
/// </summary>
|
|
public Variant[]? OpenResultOverride { get; set; }
|
|
|
|
/// <summary>
|
|
/// Number of Close invocations served so far.
|
|
/// </summary>
|
|
public int CloseCount { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Serves a Call request for the Part 5 file methods registered with
|
|
/// <see cref="AddPart5File"/>.
|
|
/// </summary>
|
|
public CallResponse Call(ArrayOf<CallMethodRequest> requests)
|
|
{
|
|
var results = new List<CallMethodResult>();
|
|
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))]
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Overrides the value returned for the Value attribute of a node.
|
|
/// </summary>
|
|
public void SetValue(NodeId nodeId, DataValue value)
|
|
{
|
|
m_values[nodeId] = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the UserAccessLevel attribute reported for a node.
|
|
/// </summary>
|
|
public void SetUserAccessLevel(NodeId nodeId, DataValue value)
|
|
{
|
|
m_accessLevels[nodeId] = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the UserRolePermissions attribute reported for a node.
|
|
/// </summary>
|
|
public void SetUserRolePermissions(NodeId nodeId, DataValue value)
|
|
{
|
|
m_rolePermissions[nodeId] = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the UserExecutable attribute reported for a method node.
|
|
/// </summary>
|
|
public void SetUserExecutable(NodeId nodeId, DataValue value)
|
|
{
|
|
m_executables[nodeId] = value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serves a Browse request from the in-memory graph.
|
|
/// </summary>
|
|
public BrowseResponse Browse(ArrayOf<BrowseDescription> nodesToBrowse)
|
|
{
|
|
BrowseCount++;
|
|
var results = new List<BrowseResult>();
|
|
for (int i = 0; i < nodesToBrowse.Count; i++)
|
|
{
|
|
BrowseDescription d = nodesToBrowse[i];
|
|
var refs = new List<ReferenceDescription>();
|
|
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 = []
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attribute ids for which the fake Read service raises a service fault.
|
|
/// </summary>
|
|
public HashSet<uint> FaultingAttributes { get; } = [];
|
|
|
|
/// <summary>
|
|
/// Serves a Read request from the in-memory graph.
|
|
/// </summary>
|
|
public ReadResponse Read(ArrayOf<ReadValueId> nodesToRead)
|
|
{
|
|
ReadCount++;
|
|
var results = new List<DataValue>();
|
|
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<NodeId, NodeId> 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.HasAddIn, 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="Mock{ISession}"/> wired to a <see cref="FakeAddressSpace"/>,
|
|
/// including the properties the client's <c>Browser</c> requires.
|
|
/// </summary>
|
|
internal static class FakeSession
|
|
{
|
|
/// <summary>
|
|
/// Creates a session mock that serves Browse and Read from the address space.
|
|
/// </summary>
|
|
public static Mock<ISession> Create(FakeAddressSpace space)
|
|
{
|
|
var telemetry = new Mock<ITelemetryContext>();
|
|
telemetry.SetupGet(t => t.LoggerFactory).Returns(NullLoggerFactory.Instance);
|
|
|
|
var messageContext = new Mock<IServiceMessageContext>();
|
|
messageContext.SetupGet(c => c.Telemetry).Returns(telemetry.Object);
|
|
messageContext.SetupGet(c => c.NamespaceUris).Returns(space.NamespaceUris);
|
|
|
|
var session = new Mock<ISession>();
|
|
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<RequestHeader>(),
|
|
It.IsAny<ViewDescription>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<ArrayOf<BrowseDescription>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns((RequestHeader _, ViewDescription _, uint _,
|
|
ArrayOf<BrowseDescription> nodes, CancellationToken _) =>
|
|
new ValueTask<BrowseResponse>(space.Browse(nodes)));
|
|
|
|
session
|
|
.Setup(s => s.ReadAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<double>(),
|
|
It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns((RequestHeader _, double _, TimestampsToReturn _,
|
|
ArrayOf<ReadValueId> nodes, CancellationToken _) =>
|
|
new ValueTask<ReadResponse>(space.Read(nodes)));
|
|
|
|
session
|
|
.Setup(s => s.CallAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ArrayOf<CallMethodRequest>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns((RequestHeader _, ArrayOf<CallMethodRequest> requests, CancellationToken _) =>
|
|
new ValueTask<CallResponse>(space.Call(requests)));
|
|
|
|
return session;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convenience helper building a TranslateBrowsePathsToNodeIds response.
|
|
/// </summary>
|
|
public static TranslateBrowsePathsToNodeIdsResponse TranslateResponse(
|
|
StatusCode statusCode,
|
|
params BrowsePathTarget[] targets)
|
|
{
|
|
return new TranslateBrowsePathsToNodeIdsResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results =
|
|
[
|
|
new BrowsePathResult
|
|
{
|
|
StatusCode = statusCode,
|
|
Targets = targets.ToArrayOf()
|
|
}
|
|
],
|
|
DiagnosticInfos = []
|
|
};
|
|
}
|
|
}
|
|
}
|