Files
Marc Schier 4756e7c245 DI (OPC 10000-100) with samples (Pump, SW Update), fluent API gap closures and model dependency implementation (#3808)
This PR implements the **OPC UA Device Integration (DI, OPC 10000-100)**
companion-spec library trio (`Opc.Ua.Di` / `Opc.Ua.Di.Server` /
`Opc.Ua.Di.Client`) plus a worked example
(`Applications/MinimalPumpServer`) demonstrating the OPC 40223 Pumps
companion specification end-to-end with full simulation. It also closes
11 fluent-API gaps in `Opc.Ua.Server.Fluent`, three source-generator
bugs, integrates the DI layer into the unified `services.AddOpcUa()`
Microsoft.Extensions DI hosting pattern, and adds 250+ new tests.

**Worked example — `Applications/MinimalPumpServer/`**
- AOT-compatible console host using `Host.CreateApplicationBuilder` +
`AddOpcUa()`.
- `PumpNodeManager` inherits from `DiNodeManager`; multi-namespace (DI +
Machinery + Pumps) via runtime NodeSet2 loading and
`ModelLoaderBuilder`.
- Demonstrates declarative second-pump creation via
`.ConfigureDevicesFor<PumpNodeManager>(...)` and software-package
seeding from the same configurator.
- Full simulation: pressure, temperatures, power, flow, efficiency,
level, cavitation/overheat fault injection, alarm setup, NAMUR-style
supervision.

**Documentation**
- `Docs/Opc.Ua.Di/` — README, DeviceBuilder, Hosting, LockService,
SoftwareUpdate, ClientHelpers (6 new developer guides).
- `Docs/Opc.Ua.Di/ComplianceMatrix.md` — every DI declaration mapped to
In-v1 / Generated / Deferred / Excluded.
- `Docs/CompanionSpecLibraries.md` — packaging guide for new
companion-spec libraries.
- `Docs/FluentApiGaps.md` — historical record of G1–G11.
- `Docs/SourceGeneratedNodeManagers.md` updated for richer node
managers.
- `Docs/plans/StateMachineBuilder.md` — deferred state-machine builder
design.
2026-06-04 05:57:57 +02:00

419 lines
15 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.Threading;
using System.Threading.Tasks;
using Opc.Ua.Client.AliasNames.Refresh;
namespace Opc.Ua.Client.AliasNames
{
/// <summary>
/// Caching alias-name resolver — wraps an
/// <see cref="AliasNameClient"/> with an in-memory
/// alias-name → <see cref="ExpandedNodeId"/>[] lookup (plus
/// reverse lookup) populated by calling <c>FindAlias</c> /
/// <c>FindAliasVerbose</c> once and reused on subsequent
/// resolutions.
/// </summary>
/// <remarks>
/// <para>
/// Default refresh mode is
/// <see cref="AliasNameResolverRefreshMode.Manual"/> — callers
/// must invoke <see cref="RefreshAsync"/> (or
/// <see cref="EnsureLoadedAsync"/>) to populate the cache. This is
/// the safe default for servers that do not allow subscriptions or
/// cannot afford the overhead of a recurring poll.
/// </para>
/// <para>
/// Opt in to automatic invalidation by setting
/// <see cref="AliasNameResolverOptions.RefreshMode"/> to
/// <see cref="AliasNameResolverRefreshMode.AutoOnLastChangePolling"/>
/// (read-based) or
/// <see cref="AliasNameResolverRefreshMode.AutoOnLastChangeMonitoredItem"/>
/// (subscription-based). Custom strategies — e.g. a Part 17
/// Annex D PubSub bridge — can be plugged in via
/// <see cref="AliasNameResolverOptions.RefreshStrategy"/>.
/// </para>
/// <para>
/// The resolver is <see cref="IAsyncDisposable"/> — disposing it
/// stops the active refresh strategy and releases any cached state.
/// </para>
/// </remarks>
public sealed class AliasNameResolver : IAsyncDisposable
{
/// <summary>
/// Initializes a new resolver over the supplied
/// <see cref="AliasNameClient"/>.
/// </summary>
public AliasNameResolver(
AliasNameClient client,
AliasNameResolverOptions? options = null)
{
Client = client ?? throw new ArgumentNullException(nameof(client));
Options = (options ?? new AliasNameResolverOptions()).Clone();
m_strategy = Options.RefreshStrategy
?? BuildBuiltInStrategy(Options);
}
/// <summary>The wrapped <see cref="AliasNameClient"/>.</summary>
public AliasNameClient Client { get; }
/// <summary>
/// The (cloned, immutable) configuration.
/// </summary>
public AliasNameResolverOptions Options { get; }
/// <summary>
/// Ensures the cache is populated; performs a refresh only on
/// the first call (or after an invalidation). On the first call
/// the configured <see cref="IAliasNameRefreshStrategy"/> is
/// also started.
/// </summary>
public async Task EnsureLoadedAsync(CancellationToken ct = default)
{
if (Volatile.Read(ref m_loaded) == 1)
{
return;
}
await EnsureStrategyStartedAsync(ct).ConfigureAwait(false);
await RefreshAsync(ct).ConfigureAwait(false);
}
private async Task EnsureStrategyStartedAsync(CancellationToken ct)
{
if (Volatile.Read(ref m_strategyStarted) == 1)
{
return;
}
await m_strategyStartLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (Volatile.Read(ref m_strategyStarted) == 1)
{
return;
}
await m_strategy.StartAsync(Client, Invalidate, ct)
.ConfigureAwait(false);
Volatile.Write(ref m_strategyStarted, 1);
}
finally
{
m_strategyStartLock.Release();
}
}
/// <summary>
/// Force-reloads the alias cache.
/// </summary>
public async Task RefreshAsync(CancellationToken ct = default)
{
var forward = new Dictionary<string, ExpandedNodeId[]>(StringComparer.Ordinal);
var serverUris = new Dictionary<string, string?[]>(StringComparer.Ordinal);
var reverse = new Dictionary<ExpandedNodeId, string>();
if (Options.UseVerbose)
{
IReadOnlyList<AliasNameVerboseDataType> verbose;
try
{
verbose = await Client.FindAliasVerboseAsync(
"%", NodeId.Null, ct).ConfigureAwait(false);
}
catch (NotSupportedException)
{
verbose = [];
Options.UseVerbose = false; // fall back to non-verbose
}
if (verbose.Count == 0 && !Options.UseVerbose)
{
// try non-verbose fallback
IReadOnlyList<AliasNameDataType> nonVerbose =
await Client.FindAliasAsync("%", NodeId.Null, ct)
.ConfigureAwait(false);
PopulateFromNonVerbose(nonVerbose, forward, reverse);
}
else
{
PopulateFromVerbose(verbose, forward, serverUris, reverse);
}
}
else
{
IReadOnlyList<AliasNameDataType> aliases =
await Client.FindAliasAsync("%", NodeId.Null, ct)
.ConfigureAwait(false);
PopulateFromNonVerbose(aliases, forward, reverse);
}
await m_semaphore.WaitAsync(ct).ConfigureAwait(false);
try
{
m_forward = forward;
m_serverUris = serverUris;
m_reverse = reverse;
Volatile.Write(ref m_loaded, 1);
}
finally
{
m_semaphore.Release();
}
}
/// <summary>
/// Returns every <see cref="ExpandedNodeId"/> mapped to the
/// alias name <paramref name="aliasName"/>, or an empty list
/// when no mapping exists. Loads the cache on demand.
/// </summary>
/// <exception cref="ArgumentNullException">
/// <paramref name="aliasName"/> is null.
/// </exception>
public async Task<IReadOnlyList<ExpandedNodeId>> ResolveAsync(
string aliasName,
CancellationToken ct = default)
{
if (aliasName == null)
{
throw new ArgumentNullException(nameof(aliasName));
}
await EnsureLoadedAsync(ct).ConfigureAwait(false);
await m_semaphore.WaitAsync(ct).ConfigureAwait(false);
try
{
if (m_forward.TryGetValue(aliasName, out ExpandedNodeId[]? targets))
{
return targets;
}
return [];
}
finally
{
m_semaphore.Release();
}
}
/// <summary>
/// Reverse lookup — returns the alias name for the supplied
/// target NodeId, or <c>null</c> if no alias points at it. Loads
/// the cache on demand.
/// </summary>
public async Task<string?> ResolveAliasNameAsync(
ExpandedNodeId target,
CancellationToken ct = default)
{
if (target.IsNull)
{
return null;
}
await EnsureLoadedAsync(ct).ConfigureAwait(false);
await m_semaphore.WaitAsync(ct).ConfigureAwait(false);
try
{
if (m_reverse.TryGetValue(target, out string? name))
{
return name;
}
return null;
}
finally
{
m_semaphore.Release();
}
}
/// <summary>
/// Returns the server URIs associated with the named alias's
/// targets (parallel to the result of
/// <see cref="ResolveAsync"/>). Only populated when the
/// resolver was loaded via
/// <see cref="AliasNameResolverOptions.UseVerbose"/>; returns an
/// empty list otherwise.
/// </summary>
/// <exception cref="ArgumentNullException">
/// <paramref name="aliasName"/> is null.
/// </exception>
public async Task<IReadOnlyList<string?>> ResolveServerUrisAsync(
string aliasName,
CancellationToken ct = default)
{
if (aliasName == null)
{
throw new ArgumentNullException(nameof(aliasName));
}
await EnsureLoadedAsync(ct).ConfigureAwait(false);
await m_semaphore.WaitAsync(ct).ConfigureAwait(false);
try
{
if (m_serverUris.TryGetValue(aliasName, out string?[]? uris))
{
return uris;
}
return [];
}
finally
{
m_semaphore.Release();
}
}
/// <summary>
/// Invalidates the cache; the next resolve will refresh.
/// </summary>
public void Invalidate()
{
Volatile.Write(ref m_loaded, 0);
}
/// <summary>
/// Stops the configured refresh strategy and releases the
/// internal cache. Idempotent. Waits for any in-flight resolve /
/// refresh / poll callback to release the internal lock before
/// disposing it so concurrent calls cannot observe an
/// <see cref="ObjectDisposedException"/>.
/// </summary>
public async ValueTask DisposeAsync()
{
try
{
await m_strategy.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best-effort cleanup; the strategy implementation is
// responsible for not throwing during dispose, but we
// never want disposal of the resolver to throw.
}
try
{
await m_semaphore.WaitAsync().ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
// Already disposed via a re-entrant call.
return;
}
try
{
m_forward.Clear();
m_serverUris.Clear();
m_reverse.Clear();
}
finally
{
m_semaphore.Release();
m_semaphore.Dispose();
m_strategyStartLock.Dispose();
}
}
private static IAliasNameRefreshStrategy BuildBuiltInStrategy(
AliasNameResolverOptions options)
{
#pragma warning disable CS0618 // AutoOnLastChange aliases AutoOnLastChangePolling.
switch (options.RefreshMode)
{
case AliasNameResolverRefreshMode.AutoOnLastChangePolling:
return new PollingAliasNameRefreshStrategy(
TimeSpan.FromMilliseconds(
Math.Max(100, options.PublishingIntervalMs)));
case AliasNameResolverRefreshMode.AutoOnLastChangeMonitoredItem:
return new MonitoredItemAliasNameRefreshStrategy(
new MonitoredItemAliasNameRefreshStrategyOptions
{
PublishingIntervalMs = options.PublishingIntervalMs,
SamplingIntervalMs = options.LastChangeSamplingIntervalMs
});
default:
return new ManualAliasNameRefreshStrategy();
}
#pragma warning restore CS0618
}
private static void PopulateFromNonVerbose(
IReadOnlyList<AliasNameDataType> aliases,
Dictionary<string, ExpandedNodeId[]> forward,
Dictionary<ExpandedNodeId, string> reverse)
{
foreach (AliasNameDataType a in aliases)
{
string key = a.AliasName.Name ?? string.Empty;
int count = a.ReferencedNodes.Count;
var arr = new ExpandedNodeId[count];
for (int i = 0; i < count; i++)
{
arr[i] = a.ReferencedNodes[i];
reverse[arr[i]] = key;
}
forward[key] = arr;
}
}
private static void PopulateFromVerbose(
IReadOnlyList<AliasNameVerboseDataType> aliases,
Dictionary<string, ExpandedNodeId[]> forward,
Dictionary<string, string?[]> serverUris,
Dictionary<ExpandedNodeId, string> reverse)
{
foreach (AliasNameVerboseDataType a in aliases)
{
string key = a.AliasName.Name ?? string.Empty;
int count = a.ReferencedNodes.Count;
var arr = new ExpandedNodeId[count];
string?[] uris = new string?[count];
for (int i = 0; i < count; i++)
{
arr[i] = a.ReferencedNodes[i];
uris[i] = i < a.ServerUris.Count ? a.ServerUris[i] : null;
reverse[arr[i]] = key;
}
forward[key] = arr;
serverUris[key] = uris;
}
}
private readonly SemaphoreSlim m_semaphore = new(1, 1);
private readonly SemaphoreSlim m_strategyStartLock = new(1, 1);
private readonly IAliasNameRefreshStrategy m_strategy;
private Dictionary<string, ExpandedNodeId[]> m_forward
= new(StringComparer.Ordinal);
private Dictionary<string, string?[]> m_serverUris
= new(StringComparer.Ordinal);
private Dictionary<ExpandedNodeId, string> m_reverse = [];
private int m_loaded;
private int m_strategyStarted;
}
}