Files
romanettandCopilot App d547747e22 Generate ReferenceNodeManager address space from a NodeSet2 model (#4220)
# Description

The CTT/compliance `ReferenceNodeManager` built its ~6,000 node address
space entirely in imperative C#. This PR migrates it to a
source-generated `[NodeManager]` partial driven by an exported NodeSet2
model (`ReferenceServer.NodeSet2.xml`), mirroring the
`MinimalBoilerServer` sample and completing the goal of issue #4204.

The address space is now materialized from the model by the source
generator. The large orchestration body was kept but converted from
create-based to **find-instead-of-create**: each `Create*` primitive now
looks up the model-materialized node via `FindPredefinedNode<T>` and
re-applies runtime values, DataAccess metadata, and write handlers,
rather than constructing nodes from scratch. This keeps the
deterministic value seeding and behavior wiring intact while the
structure comes from the model.

## Notable details for reviewers

- **Idempotent reference wiring.** The mesh references in the
`References` folder are already present in the model, so the historic
manual `AddReference` calls would throw on duplicates. Added a small
`AddReferenceIfMissing` helper so those calls are safe.
- **Non-string NodeIds.** The `NodeIds` folder assigns numeric / GUID /
opaque NodeIds. The model exports those nodes with their final ids, so
find-by-string-path no longer works; added `CreateVariableById(NodeId)`
to locate them by their real id.
- **Generator bug fix (worth a careful look).**
`ModelDesignExtensions.GetValueRankAsCode` reconstructed `ValueRank` as
`dimensions.Length + 1`, so a node with `ArrayDimensions="3,3"` was
emitted as `ValueRank = 3`. This affected **any** NodeSet2 with a fixed
multi-dimensional array (rank-3 matrices, `BadTypeMismatch` on
whole-matrix writes, wrong decoded dimensions). Corrected to
`dimensions.Length` and updated the unit tests that had codified the
off-by-one.

## Validation

- `ReferenceServerTest`: 44/44 on net10.0 and net48
- `Opc.Ua.SourceGeneration.Core.Tests`: pass on
net8.0/net9.0/net10.0/net48
- Integration on net10.0: ComplexTypes, Client, History, Sessions,
Subscriptions all pass
- Clean build, no new analyzer warnings

## Related Issues

- Fixes #4204

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [ ] 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.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-15 05:22:31 -07:00

157 lines
7.0 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 Opc.Ua;
using Opc.Ua.Server.Fluent;
namespace Quickstarts.ReferenceServer
{
/// <summary>
/// Fluent wiring for the CTT reference server methods.
/// </summary>
/// <remarks>
/// The method nodes and their input/output argument declarations are
/// baked into the NodeSet2 model via <c>&lt;Name&gt;MethodType</c>
/// declaration nodes that the instance methods point at through
/// <c>MethodDeclarationId</c>. The source generator therefore emits a
/// typed <c>OnCall</c> overload for every method, so the call handlers
/// can be expressed as plain lambdas instead of hand-written
/// <c>GenericMethodCalledEventHandler</c> callbacks that unpack and
/// repack <see cref="Opc.Ua.Variant"/> arguments by index.
/// </remarks>
public partial class ReferenceNodeManager
{
partial void Configure(IReferenceNodeManagerBuilder builder)
{
// No inputs, no outputs.
builder.CTT.Methods.Methods_Void
.OnCall(() => { });
// float + uint -> float.
builder.CTT.Methods.Methods_Add
.OnCall((value, count) => value + count);
// short * ushort -> int.
builder.CTT.Methods.Methods_Multiply
.OnCall((op1, op2) => op1 * op2);
// int / ushort -> float.
builder.CTT.Methods.Methods_Divide
.OnCall((op1, op2) => op1 / (float)op2);
// short - byte -> short.
builder.CTT.Methods.Methods_Substract
.OnCall((op1, op2) => (short)(op1 - op2));
// string -> string.
builder.CTT.Methods.Methods_Hello
.OnCall(value => "hello " + value);
// string -> void.
builder.CTT.Methods.Methods_Input
.OnCall(_ => { });
// void -> string.
builder.CTT.Methods.Methods_Output
.OnCall(() => "Output");
// Value-write handler for the simulation Enabled control variable.
// The node and its value are baked into the NodeSet2 model; only the
// write behaviour is wired here (Prio 2).
builder.CTT.Scalar.Scalar_Simulation.Scalar_Simulation_Enabled
.OnWrite((ISystemContext context, NodeState node, ref Variant value) =>
{
try
{
m_simulationEnabled = (bool)value;
return ServiceResult.Good;
}
catch (Exception e)
{
m_logger.ErrorWritingEnabledVariable(e);
return ServiceResult.Create(e, StatusCodes.Bad, "Error writing Enabled variable.");
}
});
builder.CTT.Scalar.Scalar_Simulation.Scalar_Simulation_Interval
.OnWrite(OnWriteInterval);
// The two event-trigger variables raise a simple event when written.
builder.CTT.NodeIds.NodeIds_Events.NodeIds_Events_TriggerNode01
.OnWrite((ISystemContext context, NodeState node, ref Variant value)
=> RaiseTriggerEvent(context, node));
builder.CTT.NodeIds.NodeIds_Events.NodeIds_Events_TriggerNode02
.OnWrite((ISystemContext context, NodeState node, ref Variant value)
=> RaiseTriggerEvent(context, node));
ServiceResult RaiseTriggerEvent(ISystemContext context, NodeState node)
{
var e = new BaseEventState(null);
e.Initialize(
context,
node,
EventSeverity.Medium,
new LocalizedText($"Trigger event from '{node.DisplayName.Text}'"));
Server.ReportEvent(context, e);
return ServiceResult.Good;
}
// AccessLevelEx advertises non-atomic read/write on the read/write
// static scalar. It cannot be expressed in the NodeSet2 model (the
// UANodeSet schema has no AccessLevelEx attribute), so it is applied
// here through the fluent .Node escape hatch (Prio 2). The divergent
// UserAccessLevel values, in contrast, are now baked directly into
// the NodeSet2 model (Prio 1) and loaded by the source generator.
((BaseVariableState)builder.CTT.Scalar.Scalar_Static
.Scalar_Static_NonatomicReadWrite.Node).AccessLevelEx =
AccessLevels.CurrentReadOrWrite
| (uint)AccessLevelExType.NonatomicRead
| (uint)AccessLevelExType.NonatomicWrite;
}
/// <summary>
/// Registers the periodic value simulation on the fluent builder. The
/// dynamic nodes are collected imperatively in
/// <see cref="RegisterSimulationVariables"/> (they are baked into the
/// NodeSet2 model, but the fluent surface has no per-variable
/// random-value model), while the periodic loop that pushes fresh
/// random values to them is expressed here via
/// <c>Simulation().OnTick(...)</c>. The loop fires at a short fixed
/// resolution and the tick handler applies the writable
/// <c>Scalar_Simulation_Interval</c> value.
/// </summary>
partial void Configure(INodeManagerBuilder builder)
{
builder
.Simulation(s_simulationTickInterval)
.OnTick((_, elapsed, cancellationToken) => RunSimulationStepAsync(elapsed, cancellationToken));
}
}
}