Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2e2efb4
Add async read/write hooks to BaseVariableState and AsyncCustomNodeMa…
marcschier May 12, 2026
e557ecd
Add typed IVariableBuilder<T> fluent surface
Copilot May 12, 2026
5e48d1a
Add relative-child traversal API on INodeBuilder
Copilot May 12, 2026
5007184
Mix old + new fluent APIs in MinimalBoilerServer demo
Copilot May 12, 2026
719005a
Add async-hook tests for BaseVariableState
marcschier May 12, 2026
0cf311c
Add round-trip tests for typed IVariableBuilder<T>
marcschier May 12, 2026
d40e805
Add typed model-traversal source generator
marcschier May 12, 2026
a15e8d0
Add tests for the typed model-traversal source generator
marcschier May 12, 2026
da12a62
Document the typed model-traversal Configure(I{Manager}NodeManagerBui…
marcschier May 12, 2026
f0b22de
Fix ConnectAndCloseSessionAsync AOT test
marcschier May 13, 2026
ebbdf4c
Emit fluent wrapper classes as nested types
marcschier May 13, 2026
0c2bc3f
Add snapshot tests for typed OnCall with method arguments
marcschier May 13, 2026
45b3d08
Add MinimalCalcServer sample with typed method-with-args wiring
marcschier May 13, 2026
a90e8e0
Document method-with-arguments typed OnCall path
marcschier May 13, 2026
cf3f1f3
Add Publish runtime foundation for fluent event notifiers
marcschier May 13, 2026
306c004
Add PublishTests covering EventSourceRegistry behavior
marcschier May 13, 2026
15114a6
Switch generated NodeManager base to FluentNodeManagerBase
marcschier May 13, 2026
9a0d74f
Emit typed Publish<TEvent> overloads on notifier wrappers
marcschier May 13, 2026
aef8811
Wire Publish sample, add AOT round-trip test, document event sources
marcschier May 13, 2026
9aac669
Merge remote-tracking branch 'origin/master' into sgen-server-fluent
marcschier May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 164 additions & 5 deletions Applications/MinimalBoilerServer/BoilerNodeManager.Configure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
* ======================================================================*/

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server.Fluent;
Expand All @@ -52,30 +55,62 @@ namespace Boiler
/// <em>after</em> <c>base.CreateAddressSpace</c> has materialized the
/// predefined Boiler instance, so all browse paths into the
/// <c>Boilers/Boiler #1</c> sub-tree are addressable here.
/// <para>
/// The wiring below is intentionally a mix of the four addressing
/// styles — string browse path, absolute <see cref="NodeId"/>,
/// type-definition lookup, and the new typed
/// <see cref="IVariableBuilder{TValue}"/> surface — to demonstrate
/// that the legacy and source-generator-friendly APIs interoperate.
/// </para>
/// </remarks>
[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Boiler/")]
public partial class BoilerNodeManager
{
private long m_drumLevelTicks;
private long m_pipeFlowTicks;
private long m_inputFlowTicks;
private long m_drumHeartbeatTicks;

partial void Configure(INodeManagerBuilder builder)
{
// Addressing by browse-path — works against the deployment
// tree produced by the generator from the NodeSet2.
// (1) Legacy browse-path addressing with the lower-level
// ref-Variant callback. Use this when you need full control
// over the StatusCode / SourceTimestamp returned per read.
builder
.Node("Boilers/Boiler #1/DrumX001/LIX001/Output")
.OnRead(GenerateDrumLevel);

// Addressing by absolute NodeId — use the generator's
// strongly-typed identifier table instead of a magic string.
// (2) Absolute NodeId addressing using the strongly-typed
// identifier table generated from the NodeSet2.
builder
.Node(ExpandedNodeId.ToNodeId(
VariableIds.Boilers_Boiler__1_PipeX001_FTX001_Output,
Server.NamespaceUris))
.OnRead(GeneratePipeFlow);

// Addressing by TypeDefinitionId — robust for well-known
// (3) New typed IVariableBuilder<T> via the absolute NodeId
// table — the simple Func<double> overload removes the
// ref-Variant boilerplate from the lambda and runs through
// the same sync read path as (1).
builder
.Variable<double>(ExpandedNodeId.ToNodeId(
VariableIds.Boilers_Boiler__1_FCX001_Measurement,
Server.NamespaceUris))
.OnRead(GenerateInputFlow);

// (4) New typed async IVariableBuilder<T> overload — the
// handler runs OUTSIDE the NodeState lock (lock-released
// semantics in BaseVariableState.ReadAttributeAsync), so the
// lambda may freely await without tying up a thread-pool
// thread. Hooked to the second pipe's flow output to show
// the routing end-to-end through AsyncCustomNodeManager.
builder
.Variable<double>(ExpandedNodeId.ToNodeId(
VariableIds.Boilers_Boiler__1_PipeX002_FTX002_Output,
Server.NamespaceUris))
.OnRead(GenerateOutputFlowAsync);

// (5) TypeDefinitionId addressing — robust for well-known
// singletons, independent of browse-path layout.
builder
.NodeFromTypeId(ExpandedNodeId.ToNodeId(ObjectTypeIds.BoilerType, Server.NamespaceUris))
Expand All @@ -86,6 +121,112 @@ partial void Configure(INodeManagerBuilder builder)
node.BrowseName));
}

/// <summary>
/// Source-generator-emitted typed builder partial. The fluent
/// surface here walks the model's predefined-instance tree
/// directly: each segment is a generated property whose return
/// type is the typed wrapper for the next node. Browse paths,
/// NodeIds, and namespace-index lookups are eliminated at the
/// callsite — IntelliSense surfaces every legal child, and
/// typos are compile-time errors.
/// </summary>
/// <remarks>
/// This partial coexists with <see cref="Configure(INodeManagerBuilder)"/>;
/// the generated <c>CreateAddressSpaceAsync</c> override invokes
/// both. Wiring the same node from both partials is illegal and
/// will throw at startup, so the targets here are deliberately
/// disjoint from the ones in the non-typed partial above.
/// </remarks>
partial void Configure(IBoilerNodeManagerBuilder builder)
{
// (6) Typed traversal — the LCX001 level controller measurement
// is reached via generated accessors with no string paths or
// NodeIds in sight. The Func<double> handler is the same shape
// as wiring (3) but the resolution is fully type-checked.
builder.Boilers.Boiler__1.LCX001.Measurement
.OnRead(GenerateLevelControlMeasurement);

// (7) Typed traversal of a method node — the Halt method is
// bound to an async lambda. The generator emits the typed
// OnCall(Func<CancellationToken, ValueTask>) overload that
// erases the (ISystemContext, MethodState, NodeId, ArrayOf,
// List, CancellationToken)/ServiceResult plumbing entirely.
builder.Boilers.Boiler__1.Simulation.Halt
.OnCall(HaltSimulationAsync);

// (8) Event publish source — the source-generated typed
// wrapper for DrumX001 exposes Publish<TEvent> because the
// model declares EventNotifier=SubscribeToEvents on this
// node. The factory iterator runs lazily: the registry
// activates it the first time a client subscribes to events
// on the drum (or any ancestor that walks via inverse
// HasNotifier/HasEventSource references) and cancels it once
// the last interested monitored item disappears. The
// registry auto-populates EventId/EventType/Time/SourceNode
// so the iterator only fills the user-meaningful fields.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(GenerateDrumHeartbeatAsync);
}

private long m_levelMeasurementTicks;

private double GenerateLevelControlMeasurement()
{
long t = Interlocked.Increment(ref m_levelMeasurementTicks);
return 50.0 + (10.0 * Math.Cos(t * 0.05));
}

private async ValueTask HaltSimulationAsync(CancellationToken cancellationToken)
{
// Token-aware async work to demonstrate the end-to-end async
// method call path through AsyncCustomNodeManager.CallAsync.
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
Server.Telemetry.CreateLogger<BoilerNodeManager>()
.LogInformation("Boiler simulation halted.");
}

/// <summary>
/// Lazily emits a synthetic heartbeat <see cref="BaseEventState"/>
/// every 500ms while at least one client is monitoring events on
/// the drum notifier. Cancellation tears the iterator down on the
/// last unsubscribe (or on manager disposal). The registry fills
/// in <c>EventId</c>, <c>EventType</c>, <c>SourceNode</c>,
/// <c>SourceName</c>, <c>Time</c>, and <c>ReceiveTime</c> on the
/// way out, so the iterator only sets the user-meaningful fields.
/// </summary>
private async IAsyncEnumerable<BaseEventState> GenerateDrumHeartbeatAsync(
BaseObjectState notifier,
ISystemContext context,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
Task delay = Task.Delay(
TimeSpan.FromMilliseconds(500), cancellationToken);
try
{
await delay.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
yield break;
}

long sequence = Interlocked.Increment(ref m_drumHeartbeatTicks);
var ev = new BaseEventState(parent: notifier);
ev.Severity = PropertyState<ushort>.With<VariantBuilder>(
ev, (ushort)EventSeverity.Medium);
ev.Message = PropertyState<LocalizedText>.With<VariantBuilder>(
ev,
new LocalizedText(string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"Drum heartbeat #{0}",
sequence)));
yield return ev;
}
}

private ServiceResult GenerateDrumLevel(
ISystemContext context,
NodeState node,
Expand Down Expand Up @@ -120,5 +261,23 @@ private ServiceResult GeneratePipeFlow(
timestamp = DateTimeUtc.Now;
return ServiceResult.Good;
}

private double GenerateInputFlow()
{
long t = Interlocked.Increment(ref m_inputFlowTicks);
return 80.0 + (15.0 * Math.Sin(t * 0.09));
}

private async ValueTask<double> GenerateOutputFlowAsync(CancellationToken cancellationToken)
{
// Token-aware no-op delay simulates an out-of-process source
// (a database round-trip, a remote sensor read, etc.) without
// pulling in a real I/O dependency. Cancellation correctness
// here flows all the way back to AsyncCustomNodeManager.ReadAsync.
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
long t = Interlocked.Increment(ref m_pipeFlowTicks);
return 105.0 + (25.0 * Math.Cos(t * 0.07));
}
}
}
96 changes: 96 additions & 0 deletions Applications/MinimalCalcServer/CalcNodeManager.Configure.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/

using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.Server.Fluent;

namespace Calc
{
/// <summary>
/// Source-generated <c>CustomNodeManager2</c> for the calculator
/// sample. The <c>[NodeManager]</c> attribute opts this partial class
/// in to source generation: the generator emits the sibling partial
/// that owns the predefined-node load and calls back into the
/// <c>Configure</c> partials in <c>CalcNodeManager.Configure.cs</c>.
/// </summary>
/// <remarks>
/// The calculator model intentionally exposes three method shapes —
/// sync int+int→int, async double+double→double, and reference-typed
/// string+string→string — to exercise the generator's typed
/// <c>OnCall</c> input-unpack and output-box code paths end-to-end.
/// The wiring lives in the second partial; this file holds only the
/// attribute-bearing class declaration so that the source generator
/// pipeline (<c>[NodeManager]</c> + <c>AdditionalFiles</c> NodeSet2)
/// can be reasoned about in one glance.
/// </remarks>
[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Calc/")]
public partial class CalcNodeManager
{
partial void Configure(INodeManagerBuilder builder)
{
// Intentionally empty. Kept to mirror the Boiler sample and
// demonstrate that the typed and non-typed Configure partials
// coexist on the same class — the generated address-space
// bootstrap invokes both. The calculator sample wires every
// node through the typed surface in
// Configure(ICalcNodeManagerBuilder).
}

partial void Configure(ICalcNodeManagerBuilder builder)
{
// Sync int+int→int — exercises Variant.TryGetValue<int> on
// each input arg and Variant.From<int> on the boxed result.
builder.Calculator.Add
.OnCall((int a, int b) => a + b);

// Async double+double→double — exercises the typed async
// OnCall overload (Func<double, double, CancellationToken,
// ValueTask<double>>) end-to-end through
// AsyncCustomNodeManager.CallAsync, plus Variant.From<double>
// on the boxed result.
builder.Calculator.Multiply
.OnCall(async (double x, double y, CancellationToken ct) =>
{
await Task.Yield();
ct.ThrowIfCancellationRequested();
return x * y;
});

// Sync string+string→string — exercises reference-type
// marshalling on both inputs and the output. Coalesces null
// inputs to empty so the handler is well-defined when a
// client passes a null Variant in either slot.
builder.Calculator.Concat
.OnCall((string left, string right) =>
(left ?? string.Empty) + (right ?? string.Empty));
}
}
}
37 changes: 37 additions & 0 deletions Applications/MinimalCalcServer/MinimalCalcServer.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<AssemblyName>MinimalCalcServer</AssemblyName>
<PackageId>MinimalCalcServer</PackageId>
<Company>OPC Foundation</Company>
<Description>Self-contained .NET console OPC UA server demonstrating source-generated NodeManagers + the typed fluent OnCall surface for methods with arguments. Native AOT compatible.</Description>
<Copyright>Copyright © 2004-2025 OPC Foundation, Inc</Copyright>
<RootNamespace>Calc</RootNamespace>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);CA1822</NoWarn>
<PublishAot>true</PublishAot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Libraries\Opc.Ua.Configuration\Opc.Ua.Configuration.csproj" />
<ProjectReference Include="..\..\Libraries\Opc.Ua.Server\Opc.Ua.Server.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Tools\Opc.Ua.SourceGeneration\Opc.Ua.SourceGeneration.csproj">
<OutputItemType>Analyzer</OutputItemType>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Model\Calc.xml">
<ModelSourceGeneratorModelUri>http://opcfoundation.org/UA/Calc/</ModelSourceGeneratorModelUri>
<ModelSourceGeneratorName>Calc</ModelSourceGeneratorName>
<ModelSourceGeneratorPrefix>Calc</ModelSourceGeneratorPrefix>
</AdditionalFiles>
</ItemGroup>
</Project>
Loading
Loading