Merge branch 'master' into b626-626

This commit is contained in:
Joseph Doherty
2026-10-08 15:32:51 -04:00
23 changed files with 1747 additions and 48 deletions
@@ -76,6 +76,16 @@ public static class OtOpcUaTelemetry
/// </summary>
public const string PollOverrunDriverInstanceTag = "driver_instance";
/// <summary>
/// Session/publish-level events of an OPC UA Client driver instance (#276): the operator's view of a
/// flapping upstream. Tagged <see cref="PollOverrunDriverInstanceTag"/> (<c>driver_instance</c>) +
/// <c>event</c> = <c>publish_response|notification|data_change|bad_status_value|publish_error|
/// keepalive_miss|reconnect_attempt|reconnect_success|reconnect_exhausted</c>.
/// </summary>
public static readonly Counter<long> OpcUaClientEvents =
Meter.CreateCounter<long>("otopcua.driver.opcuaclient.events", unit: "{event}",
description: "OPC UA Client driver session/publish events (event=publish_response|notification|data_change|bad_status_value|publish_error|keepalive_miss|reconnect_attempt|reconnect_success|reconnect_exhausted).");
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
public static readonly Counter<long> VirtualTagEval =
@@ -132,6 +132,14 @@ public sealed record HistoryReadResult(
byte[]? ContinuationPoint);
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
/// <remarks>
/// The first five members are the original set every historian backend serves. The remainder (#285) are
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
/// supports them (the OPC UA Client driver forwards all of them upstream). A backend that cannot serve a
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
/// <c>BadAggregateNotSupported</c> rather than a generic failure. <b>Append only</b>: the numeric values
/// are not persisted, but every <c>switch</c> over this enum must keep a total default.
/// </remarks>
public enum HistoryAggregateType
{
Average,
@@ -139,6 +147,42 @@ public enum HistoryAggregateType
Maximum,
Total,
Count,
Interpolative,
TimeAverage,
Start,
End,
Delta,
Range,
MinimumActualTime,
MaximumActualTime,
StartBound,
EndBound,
DeltaBounds,
DurationGood,
DurationBad,
PercentGood,
PercentBad,
WorstQuality,
NumberOfTransitions,
AnnotationCount,
StandardDeviationPopulation,
VariancePopulation,
}
/// <summary>
/// Thrown by a history backend when it cannot serve the requested <see cref="HistoryAggregateType"/>.
/// The Server HistoryRead path translates it to <c>BadAggregateNotSupported</c> for that node only.
/// </summary>
public sealed class HistoryAggregateNotSupportedException : NotSupportedException
{
/// <summary>The aggregate the backend could not serve.</summary>
public HistoryAggregateType Aggregate { get; }
/// <summary>Creates the exception for <paramref name="aggregate"/>.</summary>
/// <param name="aggregate">The unsupported aggregate.</param>
public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
: base($"The historian backend does not support the {aggregate} aggregate.")
=> Aggregate = aggregate;
}
/// <summary>
@@ -79,11 +79,13 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
// Mapped OUTSIDE the failure-recording try: an aggregate the gateway has no mode for is a client
// request the server answers BadAggregateNotSupported, not a backend fault to count (#285).
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
try
{
// Total/Count are now native gateway retrieval modes — no client-side scaling
// (unlike the Wonderware path that derived Total as Average × interval-seconds).
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
var buckets = new List<HistorianAggregateSample>();
await foreach (var bucket in _client
.ReadAggregateAsync(fullReference, startUtc, endUtc, mode, interval, cancellationToken)
@@ -25,7 +25,8 @@ internal static class AggregateModeMapper
/// <summary>Maps an aggregate function to the gateway retrieval mode.</summary>
/// <param name="aggregate">The driver-agnostic aggregate function.</param>
/// <returns>The matching gateway <see cref="RetrievalMode"/>.</returns>
/// <exception cref="ArgumentOutOfRangeException">A future, unmapped enum member (fails the matrix guard).</exception>
/// <exception cref="HistoryAggregateNotSupportedException">A defined member the gateway has no retrieval mode for.</exception>
/// <exception cref="ArgumentOutOfRangeException">An undefined enum value.</exception>
public static RetrievalMode ToRetrievalMode(HistoryAggregateType aggregate) => aggregate switch
{
HistoryAggregateType.Average => RetrievalMode.TimeWeightedAverage,
@@ -33,6 +34,10 @@ internal static class AggregateModeMapper
HistoryAggregateType.Maximum => RetrievalMode.MaximumWithTime,
HistoryAggregateType.Total => RetrievalMode.Integral,
HistoryAggregateType.Count => RetrievalMode.Counter,
// Part 13 aggregates the gateway has no retrieval mode for (#285) — an UNDEFINED enum value is a
// programming error, but a defined-yet-unserved member is a normal client request the server
// answers with BadAggregateNotSupported.
_ when Enum.IsDefined(aggregate) => throw new HistoryAggregateNotSupportedException(aggregate),
_ => throw new ArgumentOutOfRangeException(
nameof(aggregate), aggregate, "Unmapped HistoryAggregateType — add a RetrievalMode mapping."),
};
@@ -189,6 +189,81 @@ public sealed record OpcUaClientDriverOptions
/// </summary>
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 15;
// ---- #273 per-subscription tuning (defaults are the values that used to be hardcoded) ----
/// <summary>
/// Fixed publishing interval for every data-change subscription this driver creates. <c>null</c>
/// (default) keeps the historical behaviour: the interval the runtime asks for per
/// <c>SubscribeAsync</c> call, floored at 50 ms. When set it overrides the per-call value (the
/// 50 ms floor still applies); monitored-item sampling intervals follow it.
/// </summary>
public TimeSpan? SubscriptionPublishingInterval { get; init; }
/// <summary>
/// Publish cycles with no data before the server sends a keep-alive (default 10). Must be &gt;= 1.
/// </summary>
public uint SubscriptionKeepAliveCount { get; init; } = 10;
/// <summary>
/// Publish cycles with no Publish request before the server drops the subscription (default 1000).
/// Must be at least 3x <see cref="SubscriptionKeepAliveCount"/> (OPC UA Part 4 §5.13.2).
/// </summary>
public uint SubscriptionLifetimeCount { get; init; } = 1000;
/// <summary>Max notifications per Publish response; <c>0</c> (default) = server decides.</summary>
public uint SubscriptionMaxNotificationsPerPublish { get; init; }
/// <summary>Relative subscription priority 0-255 (default 0).</summary>
public byte SubscriptionPriority { get; init; }
// ---- #277 ----
/// <summary>Certificate-revocation / issuer-store settings. Defaults preserve the SDK behaviour.</summary>
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
// ---- #278 ----
/// <summary>
/// Optional discovery endpoint (a Local Discovery Server or the server itself), e.g.
/// <c>opc.tcp://lds.internal:4840</c>. When set, <c>InitializeAsync</c> calls <c>FindServers</c> and
/// <c>GetEndpoints</c> on it and tries the discovered URLs that match <see cref="SecurityPolicy"/> +
/// <see cref="SecurityMode"/> <i>after</i> the statically configured <see cref="EndpointUrls"/> /
/// <see cref="EndpointUrl"/> (pin the server with <see cref="DiscoveryServerUri"/>). A discovery failure is non-fatal: the static list is used. Discovery
/// itself always runs over an unsecured channel, as the spec requires. <c>null</c> (default) = off.
/// </summary>
public string? DiscoveryUrl { get; init; }
/// <summary>
/// ApplicationUri of the one server discovery may resolve to. An LDS usually registers several servers;
/// with this set only that server's endpoints are used. When unset and discovery finds more than one
/// server, connect fails with an error listing their ApplicationUris. Discovered endpoints are always
/// tried AFTER the configured <see cref="EndpointUrls"/>.
/// </summary>
public string? DiscoveryServerUri { get; init; }
}
/// <summary>
/// Server-certificate revocation handling for the OPC UA Client driver (#277). The SDK reads CRLs from
/// the <c>crl</c> sub-folder of the trusted-peer / trusted-issuer stores; these settings only decide how
/// a certificate whose revocation status cannot be determined is treated and where the issuer store lives.
/// </summary>
public sealed record OpcUaCertificateValidationOptions
{
/// <summary>
/// When <c>true</c>, a server certificate whose revocation status is unknown (no CRL for its issuer, or a
/// stale one) is rejected. Default <c>false</c> keeps brownfield deployments without CRL infrastructure
/// connecting. A certificate that IS revoked is always rejected — even with
/// <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/> set.
/// </summary>
public bool RejectUnknownRevocationStatus { get; init; }
/// <summary>
/// Directory used as the trusted-issuer certificate store. Its <c>crl/</c> sub-folder is where the SDK
/// looks for issuer CRLs (<c>.crl</c> files), so this is the knob for pointing the driver at an
/// operator-managed CRL set. <c>null</c>/empty (default) = <c>%LocalAppData%/OtOpcUa/pki/issuers</c>.
/// </summary>
public string? IssuerStorePath { get; init; }
}
/// <summary>
@@ -0,0 +1,48 @@
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// Publishes an OPC UA Client driver instance's session/publish events (#276) on the shared
/// <c>otopcua.driver.opcuaclient.events</c> OTel counter, tagged with <c>driver_instance</c> and
/// <c>event</c>. The counter is thread-safe, so the SDK's notification / keep-alive threads record
/// directly with no locking here.
/// </summary>
internal sealed class OpcUaClientDiagnostics
{
/// <summary>A NotificationMessage arrived (one per Publish response that carried data).</summary>
public const string PublishResponse = "publish_response";
/// <summary>Items carried by notification messages.</summary>
public const string Notification = "notification";
/// <summary>One monitored-item data change was delivered.</summary>
public const string DataChange = "data_change";
/// <summary>A delivered data change carried a Bad status.</summary>
public const string BadStatusValue = "bad_status_value";
/// <summary>A Publish failed (missing publish requests, subscription errors).</summary>
public const string PublishError = "publish_error";
/// <summary>A keep-alive came back Bad.</summary>
public const string KeepAliveMiss = "keepalive_miss";
/// <summary>The reconnect handler was armed.</summary>
public const string ReconnectAttempt = "reconnect_attempt";
/// <summary>A reconnect landed a new session.</summary>
public const string ReconnectSuccess = "reconnect_success";
/// <summary>A reconnect handler exhausted its retry window.</summary>
public const string ReconnectExhausted = "reconnect_exhausted";
private readonly string _driverInstanceId;
/// <summary>Creates the recorder for one driver instance.</summary>
/// <param name="driverInstanceId">Value of the <c>driver_instance</c> tag.</param>
public OpcUaClientDiagnostics(string driverInstanceId) => _driverInstanceId = driverInstanceId;
/// <summary>Add <paramref name="count"/> to the counter for <paramref name="evt"/>.</summary>
/// <param name="evt">One of the event constants.</param>
/// <param name="count">Amount to add (default 1).</param>
public void Record(string evt, long count = 1)
{
if (count <= 0) return;
OtOpcUaTelemetry.OpcUaClientEvents.Add(count,
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, _driverInstanceId),
new KeyValuePair<string, object?>("event", evt));
}
}
@@ -79,6 +79,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
_options = options;
_driverInstanceId = driverInstanceId;
_diag = new OpcUaClientDiagnostics(driverInstanceId);
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
}
@@ -144,7 +145,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <param name="map">The namespace map to install, or <c>null</c> to clear it.</param>
internal void SetNamespaceMapForTest(NamespaceMap? map) => _namespaceMap = map;
/// <summary>Test-only view of the diagnostics counters (#276).</summary>
internal OpcUaClientDiagnostics DiagnosticsForTest => _diag;
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Per-instance event counters published as OTel metrics (#276).</summary>
private readonly OpcUaClientDiagnostics _diag;
private NotificationEventHandler? _notificationHandler;
private PublishErrorEventHandler? _publishErrorHandler;
private bool _disposed;
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
private string? _connectedEndpointUrl;
@@ -212,9 +222,36 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// §8 "Namespace Assignment" — a misconfigured remote fails draft validation here,
// not as a runtime surprise.
ValidateNamespaceKind(_options);
ValidateTuning(_options);
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
var candidates = ResolveEndpointCandidates(_options);
// #278: when a DiscoveryUrl is configured, FindServers/GetEndpoints on it first and try the
// discovered URLs ahead of the static list. Non-fatal — an LDS outage must not block init.
IReadOnlyList<string> discovered = [];
if (!string.IsNullOrWhiteSpace(_options.DiscoveryUrl))
{
IReadOnlyList<DiscoveredEndpoint> found = [];
try
{
found = await DiscoverEndpointsAsync(
appConfig, _options.DiscoveryUrl!, _options.SecurityPolicy, _options.SecurityMode,
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex,
"OpcUaClientDriver '{DriverInstanceId}': discovery against '{DiscoveryUrl}' failed; " +
"using the configured endpoint list only.", _driverInstanceId, _options.DiscoveryUrl);
}
// Selection is OUTSIDE the try on purpose: an ambiguous LDS must fail the connect with a
// clear message, not be swallowed into "discovery unavailable".
discovered = SelectDiscoveredUrls(found, _options.DiscoveryServerUri);
}
var candidates = ResolveEndpointCandidates(_options, discovered);
// G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the
// shared secret store ONCE, here in the async connect flow, just before the credentials
@@ -266,6 +303,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// via TransferSubscriptions so local MonitoredItem handles stay valid.
_keepAliveHandler = OnKeepAlive;
session.KeepAlive += _keepAliveHandler;
AttachDiagnostics(session);
// Build the bidirectional namespace map from the freshly negotiated session's
// NamespaceUris (driver-specs.md §8 "Namespace Remapping"). Stored NodeIds carry
@@ -331,7 +369,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "issuers"),
// #277: the issuer store's crl/ sub-folder is where the SDK reads issuer CRLs, so an
// operator-supplied path is the CRL knob. Unset = the historical default.
StorePath = string.IsNullOrWhiteSpace(_options.CertificateValidation.IssuerStorePath)
? Path.Combine(pkiRoot, "issuers")
: _options.CertificateValidation.IssuerStorePath,
},
RejectedCertificateStore = new CertificateTrustList
{
@@ -339,6 +381,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
StorePath = Path.Combine(pkiRoot, "rejected"),
},
AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates,
RejectUnknownRevocationStatus = _options.CertificateValidation.RejectUnknownRevocationStatus,
},
TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds },
ClientConfiguration = new ClientConfiguration
@@ -364,8 +407,22 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Accept the full set of certificate-validation error codes: a real dev cert can
// fail with BadCertificateChainIncomplete, BadCertificateTimeInvalid, or
// BadCertificateHostNameInvalid, not only BadCertificateUntrusted. Only accepting
// the latter would silently fail for those certs.
CertificateValidationEventHandler handler = (_, e) => e.Accept = true;
// the latter would silently fail for those certs. A REVOKED certificate is the one
// exception (#277): auto-accept is for dev self-signed certs, never for a cert its issuer
// has explicitly withdrawn.
CertificateValidationEventHandler handler = (_, e) =>
{
if (IsRevocationFailure(e.Error.StatusCode))
{
_logger.LogWarning(
"OpcUaClientDriver '{DriverInstanceId}': REVOKED server certificate rejected " +
"(subject={Subject}, status=0x{Status:X8}) — AutoAcceptCertificates does not apply to revoked certs.",
_driverInstanceId, e.Certificate?.Subject, e.Error.StatusCode.Code);
e.Accept = false;
return;
}
e.Accept = true;
};
config.CertificateValidator.CertificateValidation += handler;
// Store refs so ShutdownAsync + Dispose can detach the delegate and avoid
// leaking a closure on a potentially process-shared validator.
@@ -381,6 +438,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return config;
}
/// <summary>True for the two hard "this certificate (or its issuer) is revoked" results (#277).</summary>
/// <param name="status">The SDK's certificate-validation status.</param>
/// <returns><c>true</c> when the status is Revoked / IssuerRevoked.</returns>
internal static bool IsRevocationFailure(StatusCode status) =>
status.Code == StatusCodes.BadCertificateRevoked || status.Code == StatusCodes.BadCertificateIssuerRevoked;
/// <summary>
/// Resolve the ordered failover candidate list. <c>EndpointUrls</c> wins when
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
@@ -388,10 +451,173 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// </summary>
/// <param name="opts">Driver options containing endpoint configuration.</param>
/// <returns>The ordered list of endpoint URLs to try.</returns>
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts)
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts) =>
ResolveEndpointCandidates(opts, []);
/// <summary>
/// Resolve the ordered failover list with optional discovery results (#278). The configured
/// endpoints always come FIRST — a discovered endpoint can only ever be a fallback, never outrank or
/// replace the server the operator named. Discovered URLs follow, de-duplicated case-insensitively.
/// With no discovered URLs the result is exactly the pre-discovery behaviour.
/// </summary>
/// <param name="opts">Driver options containing endpoint configuration.</param>
/// <param name="discovered">URLs resolved via <see cref="DiscoverEndpointsAsync"/> (may be empty).</param>
/// <returns>The ordered list of endpoint URLs to try.</returns>
internal static IReadOnlyList<string> ResolveEndpointCandidates(
OpcUaClientDriverOptions opts, IReadOnlyList<string> discovered)
{
if (opts.EndpointUrls is { Count: > 0 }) return opts.EndpointUrls;
return [opts.EndpointUrl];
IReadOnlyList<string> staticList = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls : [opts.EndpointUrl];
if (discovered.Count == 0) return staticList;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var merged = new List<string>(discovered.Count + staticList.Count);
foreach (var u in staticList)
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u)) merged.Add(u);
foreach (var u in discovered)
if (!string.IsNullOrWhiteSpace(u) && seen.Add(u)) merged.Add(u);
return merged;
}
/// <summary>
/// <c>FindServers</c> on <paramref name="discoveryUrl"/> (an LDS, or the server itself), then
/// <c>GetEndpoints</c> on each returned server's discovery URL, keeping the endpoints whose security
/// policy + mode match. One unreachable server in the list is skipped, not fatal. The discovery
/// channel is unsecured by design (OPC UA Part 4 §5.4) regardless of the session's security mode.
/// </summary>
/// <param name="appConfig">Application configuration (client identity for the discovery channel).</param>
/// <param name="discoveryUrl">The LDS / server discovery URL.</param>
/// <param name="policy">Required security policy.</param>
/// <param name="mode">Required security mode.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Matching endpoint URLs in discovery order, de-duplicated.</returns>
internal static async Task<IReadOnlyList<DiscoveredEndpoint>> DiscoverEndpointsAsync(
ApplicationConfiguration appConfig, string discoveryUrl,
OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct)
{
var wantedPolicyUri = MapSecurityPolicy(policy);
var wantedMode = ToMessageSecurityMode(mode);
ApplicationDescriptionCollection servers;
using (var lds = await DiscoveryClient.CreateAsync(
appConfig, new Uri(discoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false))
{
servers = await lds.FindServersAsync(null, ct).ConfigureAwait(false);
}
var found = new List<DiscoveredEndpoint>();
foreach (var server in servers)
{
if (server.DiscoveryUrls is null) continue;
foreach (var serverDiscoveryUrl in server.DiscoveryUrls)
{
if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue;
EndpointDescriptionCollection endpoints;
try
{
using var ep = await DiscoveryClient.CreateAsync(
appConfig, new Uri(serverDiscoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
endpoints = await ep.GetEndpointsAsync(null, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { continue; }
found.AddRange(FilterEndpointUrls(endpoints, wantedPolicyUri, wantedMode)
.Select(u => new DiscoveredEndpoint(server.ApplicationUri ?? string.Empty, u)));
}
}
return found;
}
/// <summary>One endpoint URL found by discovery, with the ApplicationUri of the server advertising it.</summary>
/// <param name="ApplicationUri">The advertising server's ApplicationUri (empty when it published none).</param>
/// <param name="Url">The endpoint URL.</param>
internal readonly record struct DiscoveredEndpoint(string ApplicationUri, string Url);
/// <summary>
/// Decide which discovered endpoints may be used (#278). An LDS can register many servers (identical
/// line PLCs, a gateway plus a historian), and the first one that accepts a connection would be bound
/// silently — mis-attributing every value and write. So: with <paramref name="serverUri"/> set, only
/// that ApplicationUri's endpoints; unset and exactly one distinct server, that server; unset and
/// several, throw naming them so the operator pins one.
/// </summary>
/// <param name="found">Matching endpoints from every server the LDS listed.</param>
/// <param name="serverUri">Optional <c>DiscoveryServerUri</c> filter.</param>
/// <returns>The usable URLs, de-duplicated, in discovery order (possibly empty).</returns>
/// <exception cref="InvalidOperationException">Several servers discovered and no filter configured.</exception>
internal static IReadOnlyList<string> SelectDiscoveredUrls(
IReadOnlyList<DiscoveredEndpoint> found, string? serverUri)
{
IEnumerable<DiscoveredEndpoint> chosen = found;
if (!string.IsNullOrWhiteSpace(serverUri))
{
chosen = found.Where(e => string.Equals(e.ApplicationUri, serverUri, StringComparison.Ordinal));
}
else
{
var servers = found.Select(e => e.ApplicationUri).Distinct(StringComparer.Ordinal).ToList();
if (servers.Count > 1)
throw new InvalidOperationException(
"OpcUaClient discovery found endpoints for more than one server " +
$"({string.Join(", ", servers.Select(u => string.IsNullOrEmpty(u) ? "<no ApplicationUri>" : u))}). " +
"Set DiscoveryServerUri to the ApplicationUri of the server this driver should use.");
}
return chosen.Select(e => e.Url).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
/// <summary>The endpoint URLs in <paramref name="endpoints"/> matching the wanted policy URI + mode.</summary>
/// <param name="endpoints">Endpoints advertised by a server.</param>
/// <param name="wantedPolicyUri">Required <c>SecurityPolicyUri</c>.</param>
/// <param name="wantedMode">Required message security mode.</param>
/// <returns>Matching <c>EndpointUrl</c>s, in advertised order.</returns>
internal static IEnumerable<string> FilterEndpointUrls(
IEnumerable<EndpointDescription> endpoints, string wantedPolicyUri, MessageSecurityMode wantedMode) =>
endpoints
.Where(e => e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode
&& !string.IsNullOrWhiteSpace(e.EndpointUrl))
.Select(e => e.EndpointUrl);
private static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) => mode switch
{
OpcUaSecurityMode.None => MessageSecurityMode.None,
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
/// <summary>
/// Range checks for the subscription-tuning and discovery options (#273/#278). Throws at
/// <see cref="InitializeAsync"/> so a bad config fails like the namespace-kind check does rather
/// than surfacing later as a server-side <c>BadInvalidArgument</c> on every subscribe.
/// </summary>
/// <param name="opts">The options to validate.</param>
internal static void ValidateTuning(OpcUaClientDriverOptions opts)
{
if (opts.SubscriptionKeepAliveCount < 1)
throw new InvalidOperationException("OpcUaClient SubscriptionKeepAliveCount must be >= 1.");
if (opts.SubscriptionLifetimeCount < 3L * opts.SubscriptionKeepAliveCount)
throw new InvalidOperationException(
"OpcUaClient SubscriptionLifetimeCount must be at least 3x SubscriptionKeepAliveCount " +
$"(got lifetime={opts.SubscriptionLifetimeCount}, keep-alive={opts.SubscriptionKeepAliveCount}).");
if (opts.SubscriptionPublishingInterval is { } pi && pi <= TimeSpan.Zero)
throw new InvalidOperationException("OpcUaClient SubscriptionPublishingInterval must be positive when set.");
if (!string.IsNullOrWhiteSpace(opts.DiscoveryUrl)
&& !Uri.TryCreate(opts.DiscoveryUrl, UriKind.Absolute, out _))
throw new InvalidOperationException(
$"OpcUaClient DiscoveryUrl '{opts.DiscoveryUrl}' is not an absolute URI.");
}
/// <summary>
/// Publishing interval (ms) a new subscription gets: the configured override if set, else the
/// interval the runtime requested, floored at 50 ms either way (servers negotiate up anyway, and
/// sub-50 ms just wastes negotiation).
/// </summary>
/// <param name="opts">Driver options.</param>
/// <param name="requested">The per-call interval from <c>SubscribeAsync</c>.</param>
/// <returns>The publishing interval in milliseconds.</returns>
internal static int ResolvePublishingIntervalMs(OpcUaClientDriverOptions opts, TimeSpan requested)
{
var effective = opts.SubscriptionPublishingInterval ?? requested;
return effective < TimeSpan.FromMilliseconds(50) ? 50 : (int)effective.TotalMilliseconds;
}
/// <summary>
@@ -510,13 +736,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
var wantedPolicyUri = MapSecurityPolicy(policy);
var wantedMode = mode switch
{
OpcUaSecurityMode.None => MessageSecurityMode.None,
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
var wantedMode = ToMessageSecurityMode(mode);
var match = all.FirstOrDefault(e =>
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
@@ -625,6 +845,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
try { sessionToClose.KeepAlive -= _keepAliveHandler; } catch { }
}
_keepAliveHandler = null;
if (sessionToClose is not null) DetachDiagnostics(sessionToClose);
Session = null;
}
@@ -1245,20 +1466,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
// bandwidth on every subscription create.
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
? 50
: (int)publishingInterval.TotalMilliseconds;
// bandwidth on every subscription create. #273: the options may pin the interval.
var intervalMs = ResolvePublishingIntervalMs(_options, publishingInterval);
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-sub-{id}",
PublishingInterval = intervalMs,
KeepAliveCount = 10,
LifetimeCount = 1000,
MaxNotificationsPerPublish = 0,
KeepAliveCount = _options.SubscriptionKeepAliveCount,
LifetimeCount = _options.SubscriptionLifetimeCount,
MaxNotificationsPerPublish = _options.SubscriptionMaxNotificationsPerPublish,
PublishingEnabled = true,
Priority = 0,
Priority = _options.SubscriptionPriority,
TimestampsToReturn = TimestampsToReturn.Both,
});
@@ -1351,6 +1570,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var dv = mn.Value;
if (dv is null) return;
var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty;
_diag.Record(OpcUaClientDiagnostics.DataChange);
if (StatusCode.IsBad(dv.StatusCode)) _diag.Record(OpcUaClientDiagnostics.BadStatusValue);
var snapshot = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
@@ -1420,9 +1641,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
DisplayName = $"opcua-alarm-sub-{id}",
PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes
KeepAliveCount = 10,
LifetimeCount = 1000,
MaxNotificationsPerPublish = 0,
KeepAliveCount = _options.SubscriptionKeepAliveCount,
LifetimeCount = _options.SubscriptionLifetimeCount,
MaxNotificationsPerPublish = _options.SubscriptionMaxNotificationsPerPublish,
PublishingEnabled = true,
Priority = 0,
TimestampsToReturn = TimestampsToReturn.Both,
@@ -1794,6 +2015,26 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total,
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range,
HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
HistoryAggregateType.StartBound => ObjectIds.AggregateFunction_StartBound,
HistoryAggregateType.EndBound => ObjectIds.AggregateFunction_EndBound,
HistoryAggregateType.DeltaBounds => ObjectIds.AggregateFunction_DeltaBounds,
HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood,
HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
HistoryAggregateType.WorstQuality => ObjectIds.AggregateFunction_WorstQuality,
HistoryAggregateType.NumberOfTransitions => ObjectIds.AggregateFunction_NumberOfTransitions,
HistoryAggregateType.AnnotationCount => ObjectIds.AggregateFunction_AnnotationCount,
HistoryAggregateType.StandardDeviationPopulation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
HistoryAggregateType.VariancePopulation => ObjectIds.AggregateFunction_VariancePopulation,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null),
};
@@ -1983,6 +2224,43 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
/// <summary>Hook the SDK's publish-side events into the diagnostics counters (#276).</summary>
/// <param name="session">The session to observe.</param>
private void AttachDiagnostics(ISession session)
{
_notificationHandler ??= (_, e) =>
{
_diag.Record(OpcUaClientDiagnostics.PublishResponse);
var msg = e.NotificationMessage;
if (msg?.NotificationData is { } data)
foreach (var nd in data)
_diag.Record(OpcUaClientDiagnostics.Notification, CountNotifications(nd));
};
_publishErrorHandler ??= (_, _) => _diag.Record(OpcUaClientDiagnostics.PublishError);
session.Notification -= _notificationHandler;
session.Notification += _notificationHandler;
session.PublishError -= _publishErrorHandler;
session.PublishError += _publishErrorHandler;
}
private void DetachDiagnostics(ISession session)
{
try
{
if (_notificationHandler is not null) session.Notification -= _notificationHandler;
if (_publishErrorHandler is not null) session.PublishError -= _publishErrorHandler;
}
catch { /* best-effort — SDK may have already cleared its invocation lists on session loss */ }
}
private static int CountNotifications(ExtensionObject nd) => nd.Body switch
{
DataChangeNotification dc => dc.MonitoredItems?.Count ?? 0,
EventNotificationList ev => ev.Events?.Count ?? 0,
StatusChangeNotification => 1,
_ => 1,
};
/// <summary>
/// Session keep-alive handler. On a healthy ping, bumps HostState back to Running
/// (typical bounce after a transient network blip). On a bad ping, starts the SDK's
@@ -1998,6 +2276,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
TransitionTo(HostState.Stopped);
_diag.Record(OpcUaClientDiagnostics.KeepAliveMiss);
// Kick off the SDK's reconnect loop exactly once per drop. Keep-alive callbacks fire
// from the SDK keep-alive timer thread and the SDK can fire this handler repeatedly
@@ -2017,6 +2296,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// BeginReconnect is started outside the lock — it does no _reconnectHandler mutation
// and we don't want to hold _probeLock across an SDK call.
_diag.Record(OpcUaClientDiagnostics.ReconnectAttempt);
handler.BeginReconnect(
sender,
(int)_options.ReconnectPeriod.TotalMilliseconds,
@@ -2057,6 +2337,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
{
newSession.KeepAlive += _keepAliveHandler;
}
// Detach-then-attach: the SDK may hand back the SAME session object, and -= on a handler that
// is not attached is a no-op, so this never double-counts.
if (oldSession is not null) DetachDiagnostics(oldSession);
if (newSession is not null) AttachDiagnostics(newSession);
Session = newSession;
@@ -2079,6 +2363,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
RecordSkippedTags(_namespaceMap);
TransitionTo(HostState.Running);
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_diag.Record(OpcUaClientDiagnostics.ReconnectSuccess);
return;
}
@@ -2091,6 +2376,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// the last-known session for an always-on gateway rather than abandoning recovery
// entirely.
TransitionTo(HostState.Faulted);
_diag.Record(OpcUaClientDiagnostics.ReconnectExhausted);
_health = new DriverHealth(
DriverState.Faulted, _health.LastSuccessfulRead,
"OPC UA session reconnect exhausted its retry window without recovering. " +
@@ -86,8 +86,18 @@ public static class OpcUaClientDriverFactoryExtensions
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
return JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
var options = JsonSerializer.Deserialize<OpcUaClientDriverOptions>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"OpcUaClient driver config for '{driverInstanceId}' deserialised to null");
// #273/#278: nothing runs DataAnnotations on driver options, so range-check here — at options load —
// and fail with a clear message instead of a driver that faults and retries forever.
try { OpcUaClientDriver.ValidateTuning(options); }
catch (InvalidOperationException ex)
{
throw new InvalidOperationException(
$"OpcUaClient driver config for '{driverInstanceId}' is invalid: {ex.Message}", ex);
}
return options;
}
}
@@ -185,6 +185,61 @@
</div>
</section>
@* Subscriptions, limits, revocation, discovery (#273/#275/#277/#278) *@
<section class="panel rise mt-3" style="animation-delay:.13s">
<div class="panel-head">Subscriptions, limits &amp; discovery</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Publishing interval override (ms)</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionPublishingIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Blank = use the interval the runtime requests per subscription.</div>
</div>
<div class="col-md-3">
<label class="form-label">Keep-alive count</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionKeepAliveCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 10.</div>
</div>
<div class="col-md-3">
<label class="form-label">Lifetime count</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionLifetimeCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 1000; at least 3x keep-alive count.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max notifications per publish</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionMaxNotificationsPerPublish" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">0 = server decides.</div>
</div>
<div class="col-md-3">
<label class="form-label">Subscription priority (0-255)</label>
<InputNumber @bind-Value="_form.OpcUa.SubscriptionPriority" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.OpcUa.RejectUnknownRevocationStatus" @bind-Value:after="EmitAsync" class="form-check-input" id="rejectUnkRevocation" />
<label class="form-check-label" for="rejectUnkRevocation">Reject unknown revocation status</label>
</div>
<div class="form-text">Revoked certificates are always rejected.</div>
</div>
<div class="col-md-6">
<label class="form-label">Issuer store / CRL directory</label>
<InputText @bind-Value="_form.OpcUa.IssuerStorePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
<div class="form-text">CRLs are read from its <code>crl/</code> sub-folder. Blank = default PKI store.</div>
</div>
<div class="col-md-6">
<label class="form-label">Discovery URL (FindServers)</label>
<InputText @bind-Value="_form.OpcUa.DiscoveryUrl" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="opc.tcp://lds:4840" />
<div class="form-text">Optional. Discovered endpoints are tried after the failover list.</div>
</div>
<div class="col-md-6">
<label class="form-label">Discovery server ApplicationUri</label>
<InputText @bind-Value="_form.OpcUa.DiscoveryServerUri" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
<div class="form-text">Required when the discovery server lists more than one server.</div>
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Diagnostics</div>
@@ -318,6 +373,18 @@
public int ProbeTimeoutSeconds { get; set; } = 15;
// #273 / #275 / #277 / #278 — without these the typed form silently drops the keys on save (#576).
public int? SubscriptionPublishingIntervalMs { get; set; }
// int, not uint/byte: InputNumber<byte> 500s the page (#505).
public int SubscriptionKeepAliveCount { get; set; } = 10;
public int SubscriptionLifetimeCount { get; set; } = 1000;
public int SubscriptionMaxNotificationsPerPublish { get; set; }
public int SubscriptionPriority { get; set; }
public bool RejectUnknownRevocationStatus { get; set; }
public string? IssuerStorePath { get; set; }
public string? DiscoveryUrl { get; set; }
public string? DiscoveryServerUri { get; set; }
internal IReadOnlyDictionary<string, string> _unsMappingTable = new Dictionary<string, string>();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
@@ -343,6 +410,15 @@
UserCertificatePassword = r.UserCertificatePassword,
TargetNamespaceKind = r.TargetNamespaceKind,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
SubscriptionPublishingIntervalMs = r.SubscriptionPublishingInterval is { } spi ? (int)spi.TotalMilliseconds : null,
SubscriptionKeepAliveCount = (int)Math.Min(r.SubscriptionKeepAliveCount, int.MaxValue),
SubscriptionLifetimeCount = (int)Math.Min(r.SubscriptionLifetimeCount, int.MaxValue),
SubscriptionMaxNotificationsPerPublish = (int)Math.Min(r.SubscriptionMaxNotificationsPerPublish, int.MaxValue),
SubscriptionPriority = r.SubscriptionPriority,
RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus,
IssuerStorePath = r.CertificateValidation.IssuerStorePath,
DiscoveryUrl = r.DiscoveryUrl,
DiscoveryServerUri = r.DiscoveryServerUri,
_unsMappingTable = r.UnsMappingTable,
};
@@ -371,6 +447,19 @@
TargetNamespaceKind = TargetNamespaceKind,
UnsMappingTable = _unsMappingTable,
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
SubscriptionPublishingInterval = SubscriptionPublishingIntervalMs is { } ms and > 0
? TimeSpan.FromMilliseconds(ms) : null,
SubscriptionKeepAliveCount = (uint)Math.Max(SubscriptionKeepAliveCount, 0),
SubscriptionLifetimeCount = (uint)Math.Max(SubscriptionLifetimeCount, 0),
SubscriptionMaxNotificationsPerPublish = (uint)Math.Max(SubscriptionMaxNotificationsPerPublish, 0),
SubscriptionPriority = (byte)Math.Clamp(SubscriptionPriority, 0, 255),
CertificateValidation = new OpcUaCertificateValidationOptions
{
RejectUnknownRevocationStatus = RejectUnknownRevocationStatus,
IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath,
},
DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl,
DiscoveryServerUri = string.IsNullOrWhiteSpace(DiscoveryServerUri) ? null : DiscoveryServerUri,
};
}
}
@@ -1243,7 +1243,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace.
UnwireAlarmNotifiers(conditionKey);
existing.Parent?.RemoveChild(existing);
PredefinedNodes?.Remove(existing.NodeId);
ForgetPredefinedSubtree(existing);
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
// dropped instance's back here rather than leave it tracked until the next full reconcile.
if (_allEventsMonitoredConditions.Remove(existing))
@@ -2499,6 +2499,89 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_ => new AlarmConditionState(parent),
};
/// <summary>
/// Gitea #629 — the <c>AlarmConditionType_ShelvingState_*</c> method instance declarations a client calls
/// the shelve methods by. <see cref="New"/> records them as the methods' <c>MethodDeclarationId</c>
/// (the SDK's own is the <c>ShelvedStateMachineType</c>'s), so calls by these ids keep resolving after
/// the methods move to instance-scoped NodeIds. Calls by the <c>ShelvedStateMachineType_*</c> ids are
/// not supported (pinned by <c>ConditionChildNodeIdTests</c>).
/// </summary>
private static readonly HashSet<NodeId> ShelvingMethodDeclarationIds =
[
MethodIds.AlarmConditionType_ShelvingState_TimedShelve,
MethodIds.AlarmConditionType_ShelvingState_OneShotShelve,
MethodIds.AlarmConditionType_ShelvingState_Unshelve,
];
/// <summary>
/// Gitea #629 — the <see cref="INodeIdFactory"/> hook <c>NodeState.Create(…, assignNodeIds: true)</c> calls
/// for the node being created and then, top-down, for every child it built from the type's definition.
/// A node that already carries an id in one of our namespaces (the condition root, whose id
/// <see cref="MaterialiseAlarmCondition"/> passes to <c>Create</c>) keeps it; a child is given
/// <c>{parent id}/{BrowseName}</c> in its parent's namespace.
/// </summary>
/// <remarks>
/// <para>
/// The SDK default (<c>CustomNodeManager2.New</c>) returns the node's CURRENT id, which for a child is
/// the condition type's instance-declaration id (<c>i=9189</c> for <c>ShelvingState/UnshelveTime</c>) or
/// an SDK-generated ns=0 number. Every condition's children therefore sat in ns=0: a
/// TranslateBrowsePathsToNodeIds of any child path returned <c>BadNoMatch</c>, and a Read of a browsed
/// child id either hit <c>BadNodeIdUnknown</c> or was served by the core node manager from the TYPE
/// (UnshelveTime read <c>0</c> on a condition timed-shelved for a minute). Event delivery and the Part 9
/// methods were unaffected — events copy the instance's fields and the SDK resolves a method by its
/// declaration id.
/// </para>
/// <para>
/// The derived id is deterministic and unique per condition: the parent part is the condition's own
/// id (a RawPath in the raw namespace, a ScriptedAlarmId in the UNS namespace) and sibling BrowseNames
/// are unique. It cannot collide with a raw node — a condition's RawPath is a tag, a leaf, so nothing
/// else is ever authored beneath it — and the namespace keeps the two realms apart. It is also
/// slash-joined on purpose: <c>DataPlaneAuthorizer.BuildScope</c> splits the identifier on <c>/</c>,
/// so a child's scope is its condition's scope plus the browse path, and the permission trie's
/// downward inheritance governs a child exactly as it governs its condition.
/// </para>
/// </remarks>
/// <param name="context">The system context of the create.</param>
/// <param name="node">The node being assigned an id.</param>
/// <returns>The id the node is to carry.</returns>
public override NodeId New(ISystemContext context, NodeState node)
{
if (OwnsNamespace(node.NodeId)) return node.NodeId;
if (node is BaseInstanceState { Parent.NodeId: { IdType: IdType.String } parentId }
&& OwnsNamespace(parentId)
&& !QualifiedName.IsNull(node.BrowseName))
{
// A client calls a Part 9 method by its TYPE's method id (ObjectId = the condition, MethodId =
// e.g. AlarmConditionType_ShelvingState_OneShotShelve, i=9212), and the SDK resolves it by NodeId OR
// MethodDeclarationId. The condition's own methods (Acknowledge, …) carry the right declaration id
// already, over an SDK-generated ns=0 NodeId. The ShelvingState methods do NOT: their declaration id
// is the ShelvedStateMachineType's (i=2948), and they were found only because their NodeId WAS the
// AlarmConditionType instance declaration every client uses. Adopt that id as the declaration before
// it is replaced, or every shelve/unshelve call fails BadMethodInvalid. A fixed set, deliberately: a
// lookup in the standard address space would take other node managers' locks while we hold Lock.
if (node is MethodState method && ShelvingMethodDeclarationIds.Contains(method.NodeId))
method.MethodDeclarationId = method.NodeId;
return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex);
}
return node.NodeId;
}
/// <summary>
/// Gitea #629 — drops a removed condition AND every descendant from <c>PredefinedNodes</c>.
/// <c>AddPredefinedNode</c> indexes the whole subtree, so removing only the root would leave the
/// condition's (now instance-scoped, hence reachable) children answering a direct Read with the dead
/// condition's last value. MUST be called under <c>Lock</c>.
/// </summary>
/// <param name="node">The node whose subtree is forgotten.</param>
private void ForgetPredefinedSubtree(NodeState node)
{
PredefinedNodes?.Remove(node.NodeId);
var children = new List<BaseInstanceState>();
node.GetChildren(SystemContext, children);
foreach (var child in children) ForgetPredefinedSubtree(child);
}
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
/// folder is a subscribable event source linked under the Server object (Server-object delivery itself is
@@ -3123,7 +3206,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
foreach (var alarm in _alarmConditions.Values)
{
alarm.Parent?.RemoveChild(alarm);
PredefinedNodes?.Remove(alarm.NodeId);
ForgetPredefinedSubtree(alarm);
}
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
@@ -3249,7 +3332,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// this they would keep a dangling inverse-notifier entry to the removed condition.
UnwireAlarmNotifiers(key);
condition.Parent?.RemoveChild(condition);
PredefinedNodes?.Remove(condition.NodeId);
ForgetPredefinedSubtree(condition);
_nativeAlarmNodeIds.Remove(key);
ReconcileAllEventsMonitoring();
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
@@ -3302,7 +3385,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (_alarmConditions.TryRemove(id, out var c))
{
c.Parent?.RemoveChild(c);
PredefinedNodes?.Remove(c.NodeId);
ForgetPredefinedSubtree(c);
}
_nativeAlarmNodeIds.Remove(id);
}
@@ -5169,6 +5252,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (HistoryAggregateNotSupportedException ex)
{
// The backend cannot serve this Part 13 aggregate (#285) — the spec status for THIS node,
// not a logged server fault.
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend does not support aggregate for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
}
catch (Exception ex)
{
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
@@ -5424,6 +5515,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
if (aggregateNodeId == ObjectIds.AggregateFunction_Interpolative) return HistoryAggregateType.Interpolative;
if (aggregateNodeId == ObjectIds.AggregateFunction_TimeAverage) return HistoryAggregateType.TimeAverage;
if (aggregateNodeId == ObjectIds.AggregateFunction_Start) return HistoryAggregateType.Start;
if (aggregateNodeId == ObjectIds.AggregateFunction_End) return HistoryAggregateType.End;
if (aggregateNodeId == ObjectIds.AggregateFunction_Delta) return HistoryAggregateType.Delta;
if (aggregateNodeId == ObjectIds.AggregateFunction_Range) return HistoryAggregateType.Range;
if (aggregateNodeId == ObjectIds.AggregateFunction_MinimumActualTime) return HistoryAggregateType.MinimumActualTime;
if (aggregateNodeId == ObjectIds.AggregateFunction_MaximumActualTime) return HistoryAggregateType.MaximumActualTime;
if (aggregateNodeId == ObjectIds.AggregateFunction_StartBound) return HistoryAggregateType.StartBound;
if (aggregateNodeId == ObjectIds.AggregateFunction_EndBound) return HistoryAggregateType.EndBound;
if (aggregateNodeId == ObjectIds.AggregateFunction_DeltaBounds) return HistoryAggregateType.DeltaBounds;
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationGood) return HistoryAggregateType.DurationGood;
if (aggregateNodeId == ObjectIds.AggregateFunction_DurationBad) return HistoryAggregateType.DurationBad;
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentGood) return HistoryAggregateType.PercentGood;
if (aggregateNodeId == ObjectIds.AggregateFunction_PercentBad) return HistoryAggregateType.PercentBad;
if (aggregateNodeId == ObjectIds.AggregateFunction_WorstQuality) return HistoryAggregateType.WorstQuality;
if (aggregateNodeId == ObjectIds.AggregateFunction_NumberOfTransitions) return HistoryAggregateType.NumberOfTransitions;
if (aggregateNodeId == ObjectIds.AggregateFunction_AnnotationCount) return HistoryAggregateType.AnnotationCount;
if (aggregateNodeId == ObjectIds.AggregateFunction_StandardDeviationPopulation) return HistoryAggregateType.StandardDeviationPopulation;
if (aggregateNodeId == ObjectIds.AggregateFunction_VariancePopulation) return HistoryAggregateType.VariancePopulation;
return null;
}