diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedAggregateFunctions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedAggregateFunctions.cs
index 7913d2cd..be8a6e8f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedAggregateFunctions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedAggregateFunctions.cs
@@ -27,6 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
///
internal static class AdvertisedAggregateFunctions
{
+ private static readonly object SyncGate = new();
/// Every standard aggregate-function NodeId the SDK's nodeset knows (ObjectIds.AggregateFunction_*).
private static readonly NodeId[] StandardAggregateIds = typeof(ObjectIds)
.GetFields(BindingFlags.Public | BindingFlags.Static)
@@ -56,10 +57,10 @@ internal static class AdvertisedAggregateFunctions
/// The advertised NodeIds, or null when the diagnostics node manager or folder is not available.
public static IReadOnlyList? Apply(IServerInternal server, IReadOnlySet served)
{
- if (server?.DiagnosticsNodeManager is not CustomNodeManager2 diagnostics) return null;
+ if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return null;
var advertised = new List();
- lock (diagnostics.Lock)
+ lock (SyncGate)
{
var folder = diagnostics.FindPredefinedNode(Folder);
if (folder is null) return null;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AggregateFunctionMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AggregateFunctionMap.cs
index ca0f6333..00be61fc 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AggregateFunctionMap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AggregateFunctionMap.cs
@@ -51,5 +51,5 @@ internal static class AggregateFunctionMap
/// The per-node aggregate-function NodeId from the request.
/// The mapped aggregate, or null.
public static HistoryAggregateType? Map(NodeId? aggregateNodeId) =>
- aggregateNodeId is not null && ByNodeId.TryGetValue(aggregateNodeId, out var aggregate) ? aggregate : null;
+ aggregateNodeId is { } nodeId && ByNodeId.TryGetValue(nodeId, out var aggregate) ? aggregate : null;
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EquipmentVariableState.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EquipmentVariableState.cs
new file mode 100644
index 00000000..a5528ed6
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/EquipmentVariableState.cs
@@ -0,0 +1,55 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
+
+///
+/// Preserves the application's established equipment-write conversions across SDK 2.0's
+/// new pre-handler type validation. The owning callback remains the authorization and shape gate.
+///
+internal sealed class EquipmentVariableState(NodeState? parent, object ownerLock) : BaseDataVariableState(parent)
+{
+ ///
+ protected override ServiceResult WriteValueAttribute(ISystemContext context, NumericRange indexRange,
+ Variant value, StatusCode statusCode, DateTimeUtc sourceTimestamp)
+ {
+ lock (ownerLock)
+ {
+ if (OnWriteValue is not { } write) return base.WriteValueAttribute(context, indexRange, value, statusCode, sourceTimestamp);
+
+ if ((AccessLevel & AccessLevels.CurrentWrite) == 0) return StatusCodes.BadNotWritable;
+ var userAccess = UserAccessLevel;
+ OnReadUserAccessLevel?.Invoke(context, this, ref userAccess);
+ if ((userAccess & AccessLevels.CurrentWrite) == 0) return StatusCodes.BadUserAccessDenied;
+
+ // Match BaseVariableState.CheckStatusAndTimestampWritable in exact tagged SDK 2.0.1.
+ if (!statusCode.Equals(StatusCodes.Good, StatusCodeComparison.CodeBitsOnly))
+ {
+ if ((AccessLevel & AccessLevels.StatusWrite) == 0) return StatusCodes.BadWriteNotSupported;
+ if ((userAccess & AccessLevels.StatusWrite) == 0) return StatusCodes.BadUserAccessDenied;
+ }
+ if (sourceTimestamp != DateTimeUtc.MinValue)
+ {
+ if ((AccessLevel & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadWriteNotSupported;
+ if ((userAccess & AccessLevels.TimestampWrite) == 0) return StatusCodes.BadUserAccessDenied;
+ }
+
+ var result = write(context, this, indexRange, default, ref value, ref statusCode, ref sourceTimestamp);
+ if (ServiceResult.IsBad(result)) return result;
+
+ // Value's first-touch setter can reset status. Commit in this order, under the shared lock.
+ Value = value;
+ StatusCode = statusCode;
+ Timestamp = sourceTimestamp == DateTimeUtc.MinValue ? DateTimeUtc.Now : sourceTimestamp;
+ ChangeMasks |= NodeStateChangeMasks.Value;
+ return result;
+ }
+ }
+
+ ///
+ protected override ServiceResult ReadValueAttribute(ISystemContext context, NumericRange indexRange,
+ QualifiedName dataEncoding, ref Variant value, ref DateTimeUtc sourceTimestamp)
+ {
+ // Subscription samplers read the node directly, bypassing the manager's service lock.
+ lock (ownerLock) return base.ReadValueAttribute(context, indexRange, dataEncoding, ref value, ref sourceTimestamp);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs
index 663cbb48..c634592a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs
@@ -102,7 +102,7 @@ internal static class HistoryBounds
/// The placeholder.
public static DataValueSnapshot NotFound(DateTime atUtc)
{
- var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound, atUtc, atUtc);
+ var placeholder = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound.Code, atUtc, atUtc);
Placeholders.AddOrUpdate(placeholder, PlaceholderMark);
return placeholder;
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryContinuationStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryContinuationStore.cs
index 14efa57d..b91a33e6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryContinuationStore.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryContinuationStore.cs
@@ -1,3 +1,4 @@
+using Opc.Ua;
using Opc.Ua.Server;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -43,8 +44,8 @@ internal interface IHistoryContinuationStore
///
/// Production backed by the OPC UA SDK's per-session
-/// history-continuation store ( /
-/// ). Using the SDK store gives us, for free:
+/// history-continuation store ( /
+/// ). Using the SDK store gives us, for free:
///
/// - per-session lifecycle — points are disposed when the session closes, so a client that
/// disconnects mid-page can never leak resume state;
@@ -75,7 +76,7 @@ internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStor
// A fresh Guid is the opaque point: 16 bytes, collision-free, and the SDK keys its session slot by
// it. The SDK enforces ServerConfiguration.MaxHistoryContinuationPoints with oldest-eviction.
var id = Guid.NewGuid();
- session.SaveHistoryContinuationPoint(id, state);
+ session.ContinuationPoints.SaveHistory(new SessionHistoryPoint(id, state));
return id.ToByteArray();
}
@@ -85,13 +86,22 @@ internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStor
if (session is null) return null;
// RestoreHistoryContinuationPoint REMOVES the slot (single-use) and returns null for an unknown /
// malformed (non-16-byte) point — exactly the "miss ⇒ BadContinuationPointInvalid" contract.
- return session.RestoreHistoryContinuationPoint(continuationPoint) as HistoryContinuationState;
+ using var point = session.ContinuationPoints.RestoreHistory(ByteString.From(continuationPoint));
+ return (point as SessionHistoryPoint)?.State;
}
///
public void Release(ISession? session, byte[] continuationPoint) =>
// Restoring removes the slot; we discard the value. Null session / unknown point ⇒ no-op.
- session?.RestoreHistoryContinuationPoint(continuationPoint);
+ session?.ContinuationPoints.ReleaseHistory(ByteString.From(continuationPoint));
+ private sealed class SessionHistoryPoint(Guid id, HistoryContinuationState state) : IHistoryContinuationPoint
+ {
+ public Guid Id { get; } = id;
+ public HistoryContinuationState State { get; } = state;
+ // The immutable resume state contains managed value data only. No handles or owned resources.
+ public void Dispose() { }
+ }
+
}
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
index bee16369..062e3593 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
@@ -143,9 +143,8 @@ internal static class HistoryPaging
if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return false;
try
{
- lock (diagnostics.Lock)
{
- var capabilities = diagnostics.GetDefaultHistoryCapabilities();
+ var capabilities = diagnostics.GetDefaultHistoryCapabilitiesAsync().AsTask().GetAwaiter().GetResult();
if (capabilities?.MaxReturnDataValues is not { } property) return false;
property.Value = (uint)Math.Max(0, maxRawValuesPerRead);
property.ClearChangeMasks(diagnostics.SystemContext, false);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
index 6a806fab..ba267785 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs
@@ -2,6 +2,7 @@ using System.Text;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Configuration;
+using Opc.Ua.Identity;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
@@ -172,7 +173,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private readonly ILogger _logger;
private ApplicationInstance? _application;
private StandardServer? _server;
- private ImpersonateEventHandler? _impersonateHandler;
+ private readonly List _identityAuthenticators = [];
/// Initializes a new instance of the OPC UA application host.
/// The host configuration options.
@@ -222,30 +223,38 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_options.PublicHostname, _options.OpcUaPort);
}
- ///
- /// Subscribes to after the SDK has its
- /// SessionManager ready (only after _application.Start). Anonymous tokens
- /// pass through; UserName tokens hit and, on
- /// success, attach a with the mapped role-set to the session
- /// so downstream ACL checks can read it via OperationContext.UserIdentity.
- ///
- /// The SDK calls ImpersonateUser synchronously off the session-activation
- /// thread, so the authenticator's async work is run via GetAwaiter().GetResult().
- /// LDAP binds typically complete in <100 ms; if a backing store ever gets that slow
- /// it should not block the OPC UA stack — callers must enforce their own timeouts inside
- /// .
- ///
private void AttachUserAuthenticator()
{
- var sessionManager = _server?.CurrentInstance?.SessionManager;
- if (sessionManager is null)
+ var registry = _server?.CurrentInstance?.IdentityRegistry
+ ?? throw new InvalidOperationException("OPC UA identity registry unavailable after start.");
+ foreach (var type in new[] { UserTokenType.Anonymous, UserTokenType.UserName })
{
- _logger.LogWarning("OpcUaApplicationHost: SessionManager unavailable after Start; UserName auth disabled");
- return;
+ var adapter = new ApplicationTokenAuthenticator(this, type);
+ registry.Register(adapter);
+ _identityAuthenticators.Add(adapter);
}
+ }
- _impersonateHandler = OnImpersonateUser;
- sessionManager.ImpersonateUser += _impersonateHandler;
+ private sealed class ApplicationTokenAuthenticator(OpcUaApplicationHost host, UserTokenType tokenType)
+ : IUserTokenAuthenticator
+ {
+ public UserTokenType TokenType => tokenType;
+ public string? IssuedTokenProfileUri => null;
+
+ public async ValueTask AuthenticateAsync(AuthenticationContext context, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ var args = new ImpersonateEventArgs(context.TokenHandler, context.UserTokenPolicy, context.EndpointDescription);
+ var peer = (host._server as OtOpcUaSdkServer)?.ActivationPeerAddress;
+ // Port zero is only an adapter for the existing IP-budget seam: the SDK exposes no peer port.
+ await HandleImpersonationAsync(host._userAuthenticator, args, host._logger, host._options.AllowAnonymous,
+ peer is null ? null : new System.Net.IPEndPoint(peer, 0), ct).ConfigureAwait(false);
+ return ServiceResult.IsBad(args.IdentityValidationError)
+ ? AuthenticationResult.Reject(args.IdentityValidationError)
+ : args.Identity is { } identity
+ ? AuthenticationResult.Accept(identity)
+ : AuthenticationResult.Reject(new ServiceResult(StatusCodes.BadIdentityTokenRejected));
+ }
}
///
@@ -298,46 +307,18 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
if (!string.IsNullOrWhiteSpace(peer) && !uris.Contains(peer))
uris.Add(peer);
}
- serverObject.ServerArray.Value = uris.ToArray();
+ serverObject.ServerArray!.Value = (ArrayOf)uris;
}
}
- private void OnImpersonateUser(ISession session, ImpersonateEventArgs args) =>
- HandleImpersonation(_userAuthenticator, args, _logger, _options.AllowAnonymous,
- // Only a UserName activation consults the endpoint (the per-client API-key miss budget, #751), so
- // the reflection walk is skipped for anonymous / X509.
- args.NewIdentity is UserNameIdentityToken ? ResolveRemoteEndpoint(session) : null);
-
- /// The remote endpoint of the client activating , or null when unknown.
- /// The session being activated.
- /// The client's IP endpoint, or null.
- ///
- /// Read from the session's secure channel — the SDK raises impersonation without the request's channel
- /// context (SecureChannelContext.Current is obsolete in 1.5.378). A client that re-activates over
- /// a NEW channel after its old one closed resolves to null and is accounted process-wide only.
- ///
- private System.Net.IPEndPoint? ResolveRemoteEndpoint(ISession? session) =>
- _server is OtOpcUaSdkServer sdk ? sdk.TryResolveRemoteEndpoint(session?.SecureChannelId) : null;
-
///
/// Pure(-ish) impersonation handler: extracted so unit tests can drive it without booting
/// the full SDK. Side-effects are confined to mutating
/// and logging.
///
///
- /// SDK threading contract (verified against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378).
- /// The SDK raises this handler SYNCHRONOUSLY — it is a plain void delegate, and there is no
- /// async impersonation callback in this SDK version. Worse, SessionManager.ActivateSessionAsync
- /// invokes it while holding a single SessionManager-wide event lock (m_eventLock), so a
- /// handler that blocks does not merely stall one SDK request thread — it SERIALIZES every session
- /// activation server-wide (Anonymous and X509 included, since even those paths must take the lock to
- /// raise the handler). Consequently the block-bridge below
- /// (GetAwaiter().GetResult() under ) is irreducible: the
- /// callback must complete inline. Because the SDK gives no timeout, the hard wall-clock bound + bounded
- /// concurrency + directory-outage circuit that keep a slow/hung directory from wedging the SDK live
- /// inside the authenticator — NOT here. (The LDAP authenticator was replaced with
- /// ApiKeyOpcUaUserAuthenticator, whose verification does no network I/O at all, so the
- /// bound/concurrency/circuit machinery that used to live there is gone rather than relocated.)
+ /// The registry path awaits authentication outside the SDK event lock. This synchronous observation
+ /// seam remains for existing handler tests; production invokes the asynchronous implementation directly.
///
/// The user authenticator to validate credentials.
/// The impersonation event arguments to process.
@@ -351,14 +332,21 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
ILogger logger,
bool allowAnonymous = true,
System.Net.IPEndPoint? remoteEndpoint = null)
+ => HandleImpersonationAsync(authenticator, args, logger, allowAnonymous, remoteEndpoint)
+ .AsTask().GetAwaiter().GetResult();
+
+ private static async ValueTask HandleImpersonationAsync(
+ IOpcUaUserAuthenticator authenticator, ImpersonateEventArgs args, ILogger logger,
+ bool allowAnonymous = true, System.Net.IPEndPoint? remoteEndpoint = null,
+ CancellationToken cancellationToken = default)
{
- if (args.NewIdentity is AnonymousIdentityToken anonymousToken)
+ if (args.UserIdentityTokenHandler.Token is AnonymousIdentityToken anonymousToken)
{
- HandleAnonymousImpersonation(authenticator, args, logger, allowAnonymous, anonymousToken);
+ await HandleAnonymousImpersonationAsync(authenticator, args, logger, allowAnonymous, anonymousToken, cancellationToken).ConfigureAwait(false);
return;
}
- if (args.NewIdentity is not UserNameIdentityToken token)
+ if (args.UserIdentityTokenHandler.Token is not UserNameIdentityToken token)
{
// X509 tokens — let the SDK's default validation stand.
return;
@@ -369,29 +357,27 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
{
// 1.5.378 exposes DecryptedPassword as raw bytes (was string); UserName token passwords
// are UTF-8 on the wire.
- var decryptedBytes = token.DecryptedPassword;
+ var decryptedBytes = (args.UserIdentityTokenHandler as UserNameIdentityTokenHandler)?.DecryptedPassword;
password = decryptedBytes is null ? string.Empty : Encoding.UTF8.GetString(decryptedBytes);
}
catch (Exception ex)
{
logger.LogWarning(ex, "OpcUaApplicationHost: failed to decrypt UserName token");
- args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
- "UserName token decryption failed");
+ args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "UserName token decryption failed");
return;
}
OpcUaUserAuthResult result;
try
{
- result = authenticator
- .AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, CancellationToken.None)
- .GetAwaiter().GetResult();
+ result = await authenticator
+ .AuthenticateUserNameAsync(token.UserName ?? string.Empty, password, remoteEndpoint, cancellationToken)
+ .ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "OpcUaApplicationHost: UserName authenticator threw for {User}", token.UserName);
- args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
- "Authentication failed");
+ args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Authentication failed");
return;
}
@@ -399,8 +385,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
{
logger.LogInformation("OpcUaApplicationHost: UserName auth denied for {User}: {Error}",
token.UserName, result.Error);
- args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
- result.Error ?? "Invalid credentials");
+ args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Invalid credentials");
return;
}
@@ -421,28 +406,25 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
/// the UserName path uses, so every downstream gate reads
/// it identically and there is no anonymous-shaped branch in any of them.
///
- private static void HandleAnonymousImpersonation(
+ private static async ValueTask HandleAnonymousImpersonationAsync(
IOpcUaUserAuthenticator authenticator,
ImpersonateEventArgs args,
ILogger logger,
bool allowAnonymous,
- AnonymousIdentityToken token)
+ AnonymousIdentityToken token, CancellationToken cancellationToken)
{
if (!allowAnonymous)
{
logger.LogInformation(
"OpcUaApplicationHost: anonymous session rejected (OpcUa:AllowAnonymous is false)");
- args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
- "Anonymous access is disabled on this server.");
+ args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is disabled on this server.");
return;
}
OpcUaUserAuthResult result;
try
{
- // Same synchronous bridge, and for the same irreducible reason, as the UserName path above.
- result = authenticator.AuthenticateAnonymousAsync(CancellationToken.None)
- .GetAwaiter().GetResult();
+ result = await authenticator.AuthenticateAnonymousAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -450,16 +432,14 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
// to the SDK default identity, because that would silently ADMIT it — the previous
// behaviour — at exactly the moment we could not determine what it is allowed to do.
logger.LogError(ex, "OpcUaApplicationHost: anonymous authenticator threw");
- args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
- "Anonymous access is unavailable.");
+ args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, "Anonymous access is unavailable.");
return;
}
if (!result.Success)
{
logger.LogInformation("OpcUaApplicationHost: anonymous session denied: {Error}", result.Error);
- args.IdentityValidationError = new ServiceResult(StatusCodes.BadIdentityTokenRejected,
- result.Error ?? "Anonymous access is denied.");
+ args.IdentityValidationError = ServiceResult.Create(StatusCodes.BadIdentityTokenRejected, result.Error ?? "Anonymous access is denied.");
return;
}
@@ -500,18 +480,12 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
var serverConfig = new ServerConfiguration
{
- BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
+ BaseAddresses = [$"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa"],
};
ApplyRequestQuotas(serverConfig, _options.Quotas);
- foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
- {
- serverConfig.SecurityPolicies.Add(policy);
- }
- foreach (var token in BuildUserTokenPolicies(_options.AllowAnonymous))
- {
- serverConfig.UserTokenPolicies.Add(token);
- }
+ serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
+ serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
var config = new ApplicationConfiguration
{
@@ -661,21 +635,20 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
{
try
{
- // An explicit SDK stop may already have halted CurrentInstance; still continue disposal.
- if (_impersonateHandler is not null && _server?.CurrentInstance?.SessionManager is { } sessionManager)
- sessionManager.ImpersonateUser -= _impersonateHandler;
+ if (_server?.CurrentInstance?.IdentityRegistry is { } registry)
+ foreach (var authenticator in _identityAuthenticators) registry.Unregister(authenticator);
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "OpcUaApplicationHost: detaching ImpersonateUser threw");
+ _logger.LogWarning(ex, "OpcUaApplicationHost: detaching identity authenticators threw");
}
- _impersonateHandler = null;
+ _identityAuthenticators.Clear();
- if (_application is not null)
+ if (_application is { } application)
{
- // 1.5.378: ApplicationInstance.Stop() → StopAsync().
- try { await _application.StopAsync().ConfigureAwait(false); }
- catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: Stop threw on dispose"); }
+ _application = null;
+ try { await application.DisposeAsync().ConfigureAwait(false); }
+ catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); }
}
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 6383b1f1..98868f3b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -131,6 +131,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
: base(server, configuration, RegisteredNamespaceUris)
{
_logger = logger ?? NullLogger.Instance;
+ // Application model events use the single ACL-filtered ReportToServerObject path.
+ ModelChangeEmissionEnabled = false;
// SystemContext is initialised by the base ctor; both namespace URIs are now registered and their
// indexes captured (in the order passed) in NamespaceIndexes.
}
@@ -180,15 +182,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The realm-qualified map key.
private RealmKey MapKey(NodeId nodeId)
{
- if (nodeId.Identifier is string s) return new RealmKey(RealmOf(nodeId), s);
+ if (nodeId.TryGetValue(out string s)) return new RealmKey(RealmOf(nodeId), s);
// Unreachable by construction — every call site passes a node this manager minted with a string
// `s=` identifier. If it ever fires, the derived key resolves to nothing and the caller sees an
// unexplained miss, so name the seam rather than collapsing quietly.
_logger.LogWarning(
"OtOpcUaNodeManager.MapKey: non-string NodeId identifier reached the map-key seam (node {NodeId}, identifier type {IdentifierType}) — the derived key will not resolve",
- nodeId, nodeId.Identifier?.GetType().Name ?? "null");
- return new RealmKey(RealmOf(nodeId), nodeId.Identifier?.ToString() ?? string.Empty);
+ nodeId, nodeId.IdType.ToString());
+ return new RealmKey(RealmOf(nodeId), nodeId.IdentifierAsString);
}
/// Gets the count of variable nodes currently managed.
@@ -402,7 +404,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (wired)
{
folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead);
- _eventNotifierSources[key] = nodeId.Identifier?.ToString() ?? string.Empty;
+ _eventNotifierSources[key] = nodeId.IdentifierAsString;
}
else
{
@@ -1073,7 +1075,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
if (!_variables.TryGetValue(key, out var variable)) return false;
- variable.Value = value;
+ variable.Value = SdkValueBoundary.FromClr(value, variable.DataType == DataTypeIds.Byte && variable.ValueRank == ValueRanks.OneDimension);
// The caller's status code reaches the SDK verbatim, sub-code included. This used to
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
@@ -1160,7 +1162,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc, UtcNow) : 0);
}
condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity));
- condition.Message.Value = new LocalizedText(state.Message);
+ condition.Message!.Value = new LocalizedText(state.Message);
// Project the source-data quality. A Good→Bad bucket change is a genuine condition
// change (it's in the delta-gate above), so it fires a Part 9 event carrying the new Quality;
@@ -1176,9 +1178,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// ConditionRefresh replays it. The event firing below also depends on this Retain being
// correct (a non-retained inactive+acked condition still fires its transition event, but
// won't be replayed on a later ConditionRefresh).
- condition.Retain.Value = state.Active || !state.Acknowledged;
- condition.Time.Value = sourceTimestampUtc;
- condition.ReceiveTime.Value = sourceTimestampUtc;
+ condition.Retain!.Value = state.Active || !state.Acknowledged;
+ condition.Time!.Value = sourceTimestampUtc;
+ condition.ReceiveTime!.Value = sourceTimestampUtc;
// T20 — fire a real Part 9 condition event ONLY when this projection is a genuine state
// change (the delta-gate decided above, against the node's pre-projection state). A
@@ -1254,8 +1256,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Fire ONLY on a real change so a steady-state connectivity re-assert doesn't spam events.
if (changed)
{
- condition.Time.Value = sourceTimestampUtc;
- condition.ReceiveTime.Value = sourceTimestampUtc;
+ condition.Time!.Value = sourceTimestampUtc;
+ condition.ReceiveTime!.Value = sourceTimestampUtc;
ReportConditionEvent(condition, sourceTimestampUtc);
}
}
@@ -1294,12 +1296,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private void ReportConditionEvent(AlarmConditionState alarm, DateTime ts)
{
// Fresh GUID-bytes EventId per event — mandatory for Part 9 ack correlation (T17 relies on it).
- alarm.EventId.Value = Guid.NewGuid().ToByteArray();
+ alarm.EventId!.Value = ByteString.From(Guid.NewGuid().ToByteArray());
// Time/ReceiveTime were already set to sourceTimestampUtc by the WriteAlarmCondition projection
// immediately above; the assignment here is a locality repeat (same value, no behavioral change)
// so the restamp is co-located with the EventId and ClearChangeMasks in the same method.
- alarm.Time.Value = ts;
- alarm.ReceiveTime.Value = ts;
+ alarm.Time!.Value = ts;
+ alarm.ReceiveTime!.Value = ts;
// Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so
// the InstanceStateSnapshot captures the just-projected values.
@@ -1401,7 +1403,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
Enabled: condition.EnabledState?.Id?.Value ?? true,
Shelving: ReadShelvingKind(condition),
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
- Message: condition.Message?.Value?.Text ?? string.Empty,
+ Message: condition.Message?.Value.Text ?? string.Empty,
// Optional Part 9 child; a node without it reads as Good (matching the accidentally-Good default
// and ToConditionDelta's fold), so a snapshot Quality can't create a phantom delta against it.
Quality: condition.Quality?.Value ?? StatusCodes.Good);
@@ -1434,7 +1436,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// unsupported shelving snapshot to, so the two stay comparable.
private static AlarmShelvingKind ReadShelvingKind(AlarmConditionState condition)
{
- var stateId = condition.ShelvingState?.CurrentState?.Id?.Value as NodeId;
+ var stateId = condition.ShelvingState?.CurrentState?.Id?.Value;
if (stateId == ObjectIds.ShelvedStateMachineType_OneShotShelved) return AlarmShelvingKind.OneShot;
if (stateId == ObjectIds.ShelvedStateMachineType_TimedShelved) return AlarmShelvingKind.Timed;
return AlarmShelvingKind.Unshelved;
@@ -1552,9 +1554,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// SourceName — the same identifying id string (RawPath native / ScriptedAlarmId scripted).
// Deliberately the UNIQUE id, NOT the leaf: the leaf collides across devices and
// is already carried by ConditionName below. See docs/AlarmTracking.md.
- alarm.EventType.Value = alarm.TypeDefinitionId;
- alarm.SourceNode.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
- alarm.SourceName.Value = alarmNodeId;
+ alarm.EventType!.Value = alarm.TypeDefinitionId;
+ alarm.SourceNode!.Value = alarm.NodeId; // Create() assigned this above; do not rebuild it
+ alarm.SourceName!.Value = alarmNodeId;
// The mandatory ConditionType classification fields, unset by Create() for the same reason as
// the fields above (mandatory, no default, nothing downstream synthesises them) ⇒ NodeId.Null + empty
@@ -1597,8 +1599,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// represent (int.MaxValue ms ≈ 24.8 days, the same ceiling SetShelvingState already saturated a 0 to).
if (alarm.MaxTimeShelved is { } maxTimeShelved && maxTimeShelved.Value <= 0)
maxTimeShelved.Value = int.MaxValue;
- alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
- alarm.Message.Value = new LocalizedText(displayName);
+ alarm.Retain!.Value = false; // inactive + acked ⇒ nothing to retain yet
+ alarm.Message!.Value = new LocalizedText(displayName);
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
// T18 — inbound Part 9 method handlers. Create() materialised the Acknowledge/Confirm/
@@ -1740,9 +1742,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Its carried state.
private NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new(
ReadConditionDelta(condition),
- condition.Time?.Value ?? UtcNow,
- condition.ActiveState?.TransitionTime?.Value,
- condition.Quality?.SourceTimestamp?.Value,
+ condition.Time?.Value.ToDateTime() ?? UtcNow,
+ condition.ActiveState?.TransitionTime?.Value.ToDateTime(),
+ condition.Quality?.SourceTimestamp?.Value.ToDateTime(),
condition.Comment?.Value,
condition.ClientUserId?.Value);
@@ -1789,12 +1791,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
alarm.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, state.Confirmed);
alarm.SetSeverity(SystemContext, (EventSeverity)state.MappedSeverity);
- alarm.Message.Value = new LocalizedText(state.Message);
- alarm.Retain.Value = true;
- if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment;
+ alarm.Message!.Value = new LocalizedText(state.Message);
+ alarm.Retain!.Value = true;
+ if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment.Value;
if (alarm.ClientUserId is not null && carried.ClientUserId is not null) alarm.ClientUserId.Value = carried.ClientUserId;
- alarm.Time.Value = carried.Time;
- alarm.ReceiveTime.Value = carried.Time;
+ alarm.Time!.Value = carried.Time;
+ alarm.ReceiveTime!.Value = carried.Time;
_restoredConditionReports.Add((conditionKey, alarm));
}
@@ -1888,8 +1890,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// dedups by a linear ReferenceEquals walk of the condition's notifier list, so re-wiring K
// folders on an idempotent re-apply cost O(K²) for nothing.
if (!wiring.Folders.Add(folder)) continue;
- alarm.AddNotifier(SystemContext, null, isInverse: true, folder);
- folder.AddNotifier(SystemContext, null, isInverse: false, alarm);
+ alarm.AddNotifier(SystemContext, NodeId.Null, isInverse: true, folder);
+ folder.AddNotifier(SystemContext, NodeId.Null, isInverse: false, alarm);
EnsureFolderIsEventNotifier(folder);
}
}
@@ -1982,7 +1984,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// uncommented re-ack still changes nothing and stays silent.
alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) =>
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true,
- alwaysReport: !LocalizedText.IsNullOrEmpty(comment));
+ alwaysReport: !comment.IsNullOrEmpty);
}
WrapComment(alarm.Confirm, alwaysReport: false);
@@ -2026,7 +2028,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
lock (Lock)
{
var before = ReadConditionDelta(alarm);
- var retainBefore = alarm.Retain.Value;
+ var retainBefore = alarm.Retain!.Value;
var result = sdkHandler();
// A rebuild may have discarded this instance mid-call; its replacement is not what was called.
@@ -2045,13 +2047,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// Native: no confirm workflow — keep what the driver projection says (see remarks).
alarm.SetConfirmedState(SystemContext, true);
- alarm.Retain.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true);
+ alarm.Retain!.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true);
}
else if (before.Acknowledged)
{
// Re-ack: the SDK flipped Confirmed though nothing was acknowledged; put it back.
alarm.SetConfirmedState(SystemContext, before.Confirmed);
- alarm.Retain.Value = retainBefore;
+ alarm.Retain!.Value = retainBefore;
}
}
@@ -2102,7 +2104,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (gate is not null) return gate;
var cmd = new AlarmCommand(
- AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
+ AlarmId: condition.NodeId.IdentifierAsString,
Operation: operation,
User: AlarmOperatorName(identity!),
Comment: comment?.Text,
@@ -2148,15 +2150,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// reverted by the driver's next transition. Nothing is routed, applied or reported.
if (NativeAlarmAckPrimaryGate is { } primary && !primary())
{
- return new ServiceResult(StatusCodes.BadInvalidState,
- new LocalizedText("This server is not the redundancy Primary; acknowledge the alarm on the Primary (the server with the higher ServiceLevel)."));
+ return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText("This server is not the redundancy Primary; acknowledge the alarm on the Primary (the server with the higher ServiceLevel)."));
}
// Same condition-node-id extraction HandleAlarmCommand uses (condition.NodeId.Identifier?.ToString()):
// for a native condition this is the folder-scoped NodeId string the DriverHostActor inverse map
// (_alarmNodeIdByDriverRef value set) keys by, so the next task can resolve it back to (driver, ref).
var ack = new NativeAlarmAck(
- ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
+ ConditionNodeId: condition.NodeId.IdentifierAsString,
Comment: comment?.Text,
OperatorUser: AlarmOperatorName(identity!),
ApiKeyId: identity!.ApiKeyId);
@@ -2282,7 +2283,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
private ServiceResult OnEquipmentTagWrite(
ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
- ref object value, ref StatusCode statusCode, ref DateTime timestamp)
+ ref Variant value, ref StatusCode statusCode, ref DateTimeUtc timestamp)
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
var gateway = _nodeWriteGateway;
@@ -2296,7 +2297,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// "writes unavailable" on a node with no gateway.
var gate = AclEnforced
? _authorizer!.Authorize(identity, OpcUaOperation.WriteOperate, node.NodeId, RealmOf(node.NodeId))
- ?? (gatewayWired ? null : new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable"))
+ ?? (gatewayWired ? null : ServiceResult.Create(StatusCodes.BadNotWritable, "writes unavailable"))
: _authorizer?.AuthorizeKeyUsable(identity, OpcUaOperation.WriteOperate, node.NodeId)
?? EvaluateEquipmentWriteGate(identity, gatewayWired);
if (gate is not null) return gate;
@@ -2310,24 +2311,24 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var refuseArrayOnScalar = _arrayOnScalarRefused.ContainsKey(MapKey(node.NodeId));
var range = EvaluateEquipmentWriteRange(indexRange);
if (range is not null) return range;
- var structure = EvaluateEquipmentWriteStructure(value, node, refuseArrayOnScalar, out var coerced);
+ var structure = EvaluateEquipmentWriteStructure(SdkValueBoundary.ToClr(value), node, refuseArrayOnScalar, out var coerced);
if (structure is not null) return structure;
- value = coerced!;
+ value = SdkValueBoundary.FromClr(coerced, node is BaseDataVariableState { DataType: var dt, ValueRank: ValueRanks.OneDimension } && dt == DataTypeIds.Byte);
// Capture the optimistic value + the REAL prior value/status BEFORE the SDK applies the write
// (at handler entry the node still holds the prior value; returning Good makes the SDK apply `value`).
- var optimisticValue = value;
+ var optimisticValue = coerced;
// v3 dual-namespace: route by the FULL (namespace-qualified) NodeId string so a Raw write and a UNS
// write to nodes that share a bare id are distinguishable downstream — the write gateway keys its
// (DriverInstanceId, RawPath) resolution on this ns-qualified id. Revert keys off the bare id + realm.
var nodeKey = node.NodeId.ToString();
var nodeRealm = RealmOf(node.NodeId);
- var nodeBareId = node.NodeId.Identifier?.ToString() ?? string.Empty;
+ var nodeBareId = node.NodeId.IdentifierAsString;
object? priorValue = null;
StatusCode priorStatus = StatusCodes.Good;
if (node is BaseDataVariableState variable)
{
- priorValue = variable.Value;
+ priorValue = SdkValueBoundary.ToClr(variable.Value);
priorStatus = variable.StatusCode;
}
// Item C: thread the writing principal's user-id string into the failure continuation so the audit
@@ -2368,7 +2369,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
catch (Exception ex)
{
_logger.LogWarning(ex, "OtOpcUaNodeManager: write dispatch for {NodeId} threw synchronously; the write is refused", nodeKey);
- return new ServiceResult(StatusCodes.BadDeviceFailure, $"write dispatch threw: {ex.Message}");
+ return ServiceResult.Create(StatusCodes.BadDeviceFailure, $"write dispatch threw: {ex.Message}");
}
_ = dispatch.ContinueWith(
@@ -2424,7 +2425,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (!gatewayWired)
{
// Gate passed but no gateway wired (admin-only nodes / pre-boot) ⇒ writes unavailable.
- return new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable");
+ return ServiceResult.Create(StatusCodes.BadNotWritable, "writes unavailable");
}
return null;
@@ -2501,7 +2502,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Minimum sensible check: a null payload is never a valid value write to a value variable.
if (value is null)
{
- return new ServiceResult(StatusCodes.BadTypeMismatch, "null value write rejected");
+ return ServiceResult.Create(StatusCodes.BadTypeMismatch, "null value write rejected");
}
// An array node (writable only when its driver encodes whole-array writes) takes a whole
@@ -2514,20 +2515,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
isArrayNode = true;
if (value is not Array { Rank: 1 } array)
{
- return new ServiceResult(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
+ return ServiceResult.Create(StatusCodes.BadTypeMismatch, "a 1-D array node takes a 1-D array value");
}
var declared = arrayNode.ArrayDimensions is { Count: > 0 } dims ? dims[0] : 0u;
if (declared > 0 && array.Length != declared)
{
- return new ServiceResult(
- StatusCodes.BadTypeMismatch,
- $"array write of {array.Length} element(s) to a {declared}-element array node");
+ return ServiceResult.Create(StatusCodes.BadTypeMismatch, $"array write of {array.Length} element(s) to a {declared}-element array node");
}
}
// Cheap, confidence-gated built-in-type compatibility check. Only when the node is a value variable
// with a concretely-resolvable expected built-in type AND the payload's built-in type is also cheaply
- // resolvable do we compare; otherwise proceed and let the SDK's WriteValue coercion be authoritative.
+ // resolvable do we compare; otherwise proceed and let the owning driver perform its existing scalar range checks.
if (node is not BaseDataVariableState variable) return null;
var expected = TypeInfo.GetBuiltInType(variable.DataType); // NodeId ⇒ built-in; abstract/unknown ⇒ Null
if (expected is BuiltInType.Null or BuiltInType.Variant or BuiltInType.DataValue) return null; // unresolved / wildcard
@@ -2542,7 +2541,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// takes one). An unresolved-DataType node deferred above, as it always has.
if (refuseArrayOnScalar && variable.ValueRank == ValueRanks.Scalar && value is Array and not byte[])
{
- return new ServiceResult(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
+ return ServiceResult.Create(StatusCodes.BadTypeMismatch, "a scalar node takes a scalar value");
}
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
@@ -2583,22 +2582,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
coerced = converted;
return null;
}
- return new ServiceResult(
- StatusCodes.BadTypeMismatch,
- $"array element of type {actual} is not exactly representable as node data type {expected}");
+ return ServiceResult.Create(StatusCodes.BadTypeMismatch, $"array element of type {actual} is not exactly representable as node data type {expected}");
}
- return new ServiceResult(
- StatusCodes.BadTypeMismatch,
- $"array element type {actual} does not match node data type {expected}");
+ return ServiceResult.Create(StatusCodes.BadTypeMismatch, $"array element type {actual} does not match node data type {expected}");
}
if (actual == BuiltInType.Null) return null; // couldn't classify the payload ⇒ defer to the SDK
if (!IsBuiltInTypeCompatible(expected, actual))
{
- return new ServiceResult(
- StatusCodes.BadTypeMismatch,
- $"value built-in type {actual} is not compatible with node data type {expected}");
+ return ServiceResult.Create(StatusCodes.BadTypeMismatch, $"value built-in type {actual} is not compatible with node data type {expected}");
}
return null;
@@ -2611,12 +2604,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// can be stale or optimistic), and the SDK would apply the range to the node before the device answered.
/// A range on a scalar node is meaningless and refused the same way. Pure + static.
///
- /// The write's parsed IndexRange ( when absent).
+ /// The write's parsed IndexRange ( when absent).
/// null to proceed; otherwise BadWriteNotSupported.
internal static ServiceResult? EvaluateEquipmentWriteRange(NumericRange indexRange) =>
- indexRange == NumericRange.Empty
+ indexRange == default(NumericRange)
? null
- : new ServiceResult(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
+ : ServiceResult.Create(StatusCodes.BadWriteNotSupported, "IndexRange writes are not supported; write the whole array");
///
/// Conservative built-in-type compatibility test for 's
@@ -2797,7 +2790,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
lock (Lock)
{
if (!_variables.TryGetValue(key, out var variable)) return; // rebuilt/removed ⇒ no-op
- if (!ShouldRevert(outcome, variable.Value, optimisticValue)) return; // success, or poll moved it on
+ if (!ShouldRevert(outcome, SdkValueBoundary.ToClr(variable.Value), optimisticValue)) return; // success, or poll moved it on
// Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore
// the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's
@@ -2807,7 +2800,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
variable.Timestamp = now;
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip
- variable.Value = priorValue;
+ variable.Value = SdkValueBoundary.FromClr(priorValue, variable.DataType == DataTypeIds.Byte && variable.ValueRank == ValueRanks.OneDimension);
variable.StatusCode = priorStatus;
variable.Timestamp = now;
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior
@@ -2874,8 +2867,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// (verified against the 1.5.378 SDK; it tolerates a null value, creating the child with Value=null).
audit.SetChildValue(SystemContext, BrowseNames.AttributeId, (uint)Attributes.Value, false);
audit.SetChildValue(SystemContext, BrowseNames.IndexRange, string.Empty, false);
- audit.SetChildValue(SystemContext, BrowseNames.OldValue, priorValue!, false);
- audit.SetChildValue(SystemContext, BrowseNames.NewValue, optimisticValue!, false);
+ audit.SetChildValue(SystemContext, BrowseNames.OldValue, SdkValueBoundary.FromClr(priorValue), false);
+ audit.SetChildValue(SystemContext, BrowseNames.NewValue, SdkValueBoundary.FromClr(optimisticValue), false);
// Standard AuditEvent client-identity fields. ClientUserId is the writing principal (threaded from the
// handler); ClientAuditEntryId carries the SDK context's audit entry id when present.
@@ -2964,7 +2957,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (OwnsNamespace(node.NodeId)) return node.NodeId;
if (node is BaseInstanceState { Parent.NodeId: { IdType: IdType.String } parentId }
&& OwnsNamespace(parentId)
- && !QualifiedName.IsNull(node.BrowseName))
+ && !node.BrowseName.IsNull)
{
// 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
@@ -2977,7 +2970,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (node is MethodState method && ShelvingMethodDeclarationIds.Contains(method.NodeId))
method.MethodDeclarationId = method.NodeId;
- return new NodeId($"{parentId.Identifier}/{node.BrowseName.Name}", parentId.NamespaceIndex);
+ return new NodeId($"{parentId.IdentifierAsString}/{node.BrowseName.Name}", parentId.NamespaceIndex);
}
return node.NodeId;
}
@@ -3032,7 +3025,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Key by the realm-qualified RealmKey built from the NodeId so a Raw and a UNS folder that share
// the same bare id are distinct sources; the VALUE stays the bare id — that is the historian
// ReadEventsAsync sourceName (== the equipment id).
- var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty;
+ var sourceName = folder.NodeId.IdentifierAsString;
_eventNotifierSources[MapKey(folder.NodeId)] = sourceName;
}
AddRootNotifier(folder);
@@ -3085,7 +3078,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
NodeId = new NodeId(folderNodeId, ns),
BrowseName = new QualifiedName(folderNodeId, ns),
- DisplayName = displayName,
+ DisplayName = new LocalizedText(displayName),
EventNotifier = EventNotifiers.None,
TypeDefinitionId = ObjectTypeIds.FolderType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
@@ -3143,7 +3136,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
lock (Lock)
{
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return false;
- folder.DisplayName = displayName;
+ folder.DisplayName = new LocalizedText(displayName);
folder.ClearChangeMasks(SystemContext, includeChildren: false);
return true;
}
@@ -3203,20 +3196,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is
// CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized.
var access = ComposeAccessLevel(writable, historized, _historianDataUpdater is not null);
- var variable = new BaseDataVariableState(parent)
+ var variable = new EquipmentVariableState(parent, Lock)
{
NodeId = new NodeId(variableNodeId, ns),
BrowseName = new QualifiedName(variableNodeId, ns),
- DisplayName = displayName,
+ DisplayName = new LocalizedText(displayName),
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = ResolveBuiltInDataType(dataType),
ValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
- ArrayDimensions = isArray ? new ReadOnlyList(new UInt32Collection(new[] { arrayLength ?? 0u })) : null,
+ ArrayDimensions = isArray ? (ArrayOf)[arrayLength ?? 0u] : default,
AccessLevel = access,
UserAccessLevel = access,
Historizing = historized,
- Value = null,
+ Value = Variant.Null,
StatusCode = StatusCodes.BadWaitingForInitialData,
Timestamp = DateTime.MinValue,
};
@@ -3318,11 +3311,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
v.DataType = newDataType;
v.ValueRank = newValueRank;
v.ArrayDimensions = isArray
- ? new ReadOnlyList(new UInt32Collection(new[] { arrayLength ?? 0u }))
- : null;
+ ? (ArrayOf)[arrayLength ?? 0u]
+ : default;
// Drop the stale (old-typed) value — mirrors EnsureVariable's fresh-node state so a client
// never reads a value whose runtime type contradicts the just-changed DataType.
- v.Value = null;
+ v.Value = Variant.Null;
v.StatusCode = StatusCodes.BadWaitingForInitialData;
v.Timestamp = DateTime.MinValue;
shapeChangedNode = v;
@@ -3375,7 +3368,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
};
// SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder
// relies on for its child PropertyStates).
- e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
+ e.SetChildValue(SystemContext, BrowseNames.Changes, (ArrayOf)[change], false);
return e;
}
@@ -3566,7 +3559,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
};
// SetChildValue lazily creates + sets the Changes property (same pattern the audit-event builder
// relies on for its child PropertyStates).
- e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
+ e.SetChildValue(SystemContext, BrowseNames.Changes, (ArrayOf)[change], false);
return e;
}
@@ -3599,7 +3592,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm),
Verb = (byte)ModelChangeStructureVerbMask.NodeDeleted,
};
- e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
+ e.SetChildValue(SystemContext, BrowseNames.Changes, (ArrayOf)[change], false);
return e;
}
@@ -3784,7 +3777,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// The node's CURRENT Time: the carried instant when nothing reported since the restore, or the
// newer instant of a report that did (a client method call on an SDK thread can land between the
// restore and here) — never roll the condition's Time back.
- ReportConditionEvent(alarm, alarm.Time.Value);
+ ReportConditionEvent(alarm, alarm.Time!.Value.ToDateTime());
reported++;
}
@@ -3858,9 +3851,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
node.GetReferences(SystemContext, references);
foreach (var r in references)
{
- if (r.IsInverse || r.TargetId is null || r.TargetId.IsAbsolute) continue;
+ if (r.IsInverse || r.TargetId.IsNull || r.TargetId.IsAbsolute) continue;
var targetNodeId = (NodeId)r.TargetId;
- if (NodeId.IsNull(targetNodeId)) continue;
+ if (targetNodeId.IsNull) continue;
if (ResolveNodeState(MapKey(targetNodeId)) is not { } target) continue;
if (!target.RemoveReference(r.ReferenceTypeId, isInverse: true, node.NodeId)) continue;
target.ClearChangeMasks(SystemContext, includeChildren: false);
@@ -4055,8 +4048,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The node id to test.
/// when this manager owns the node's namespace.
private bool OwnsNamespace(NodeId? nodeId) =>
- nodeId is not null
- && (nodeId.NamespaceIndex == NamespaceIndexes[0] || nodeId.NamespaceIndex == NamespaceIndexes[1]);
+ nodeId is { } id
+ && (id.NamespaceIndex == NamespaceIndexes[0] || id.NamespaceIndex == NamespaceIndexes[1]);
/// Pulls the ACL identity off an SDK operation context.
/// The SDK context carrying the session identity.
@@ -4089,7 +4082,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public override void Read(
OperationContext context,
double maxAge,
- IList nodesToRead,
+ ArrayOf nodesToRead,
IList values,
IList errors)
{
@@ -4106,7 +4099,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
nodeToRead.Processed = true;
errors[ii] = StatusCodes.BadUserAccessDenied;
- values[ii] = new DataValue(StatusCodes.BadUserAccessDenied);
+ values[ii] = new DataValue(Variant.Null, StatusCodes.BadUserAccessDenied);
denied++;
}
@@ -4139,7 +4132,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
if (!AclEnforced)
{
- base.Browse(context, ref continuationPoint, references);
+ base.Browse(context, ref continuationPoint!, references);
return;
}
@@ -4150,13 +4143,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// only the reference filtering below.
var source = (continuationPoint?.NodeToBrowse as NodeHandle)?.NodeId;
- if (source is not null && !Permits(identity, OpcUaOperation.Browse, source))
+ if (source is not null && !Permits(identity, OpcUaOperation.Browse, source.Value))
throw new ServiceResultException(StatusCodes.BadUserAccessDenied);
// Only the references THIS call appends are ours to filter — on a BrowseNext the list already
// carries the earlier pages, and re-examining them would both waste work and skew the summary.
var before = references.Count;
- base.Browse(context, ref continuationPoint, references);
+ base.Browse(context, ref continuationPoint!, references);
var returned = references.Count - before;
// Forward two-pointer compaction over the appended range, NOT a per-denial RemoveAt. Removing
@@ -4171,11 +4164,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// ExpandedNodeId → NodeId only makes sense for a reference into our own server; a reference
// to another server's node is left alone, as this manager has no grants that could speak to it.
var target = references[ii].NodeId;
- var keep = target is null
+ var keep = target.IsNull
|| target.IsAbsolute
- || ExpandedNodeId.ToNodeId(target, Server.NamespaceUris) is not { } targetId
- || !OwnsNamespace(targetId)
- || Permits(identity, OpcUaOperation.Browse, targetId);
+ || !OwnsNamespace(ExpandedNodeId.ToNodeId(target, Server.NamespaceUris))
+ || Permits(identity, OpcUaOperation.Browse, ExpandedNodeId.ToNodeId(target, Server.NamespaceUris));
if (!keep)
{
@@ -4230,7 +4222,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
for (var ii = before; ii < targetIds.Count; ii++)
{
var targetId = ExpandedNodeId.ToNodeId(targetIds[ii], Server.NamespaceUris);
- var keep = targetId is null
+ var keep = targetId.IsNull
|| !OwnsNamespace(targetId)
|| Permits(identity, OpcUaOperation.Browse, targetId);
@@ -4251,7 +4243,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
uint subscriptionId,
double publishingInterval,
TimestampsToReturn timestampsToReturn,
- IList itemsToCreate,
+ ArrayOf itemsToCreate,
IList errors,
IList filterErrors,
IList monitoredItems,
@@ -4268,7 +4260,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var item = itemsToCreate[ii];
var target = item.ItemToMonitor?.NodeId;
if (item.Processed || !OwnsNamespace(target)) continue;
- if (Permits(identity, OpcUaOperation.CreateMonitoredItems, target!)) continue;
+ if (Permits(identity, OpcUaOperation.CreateMonitoredItems, target!.Value)) continue;
item.Processed = true;
errors[ii] = StatusCodes.BadUserAccessDenied;
@@ -4327,7 +4319,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (IsHandleInNamespace(sourceId) is { } h
&& (!MonitoredNodes.TryGetValue(h.NodeId, out var current)
- || current.EventMonitoredItems?.Contains(monitoredItem) != true))
+ || current.EventMonitoredItems?.ContainsKey(monitoredItem.Id) != true))
{
return ServiceResult.Good;
}
@@ -4366,7 +4358,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
lock (Lock)
{
if (MonitoredNodes.TryGetValue(handle.NodeId, out var monitored)
- && monitored.EventMonitoredItems?.Contains(monitoredItem) == true)
+ && monitored.EventMonitoredItems?.ContainsKey(monitoredItem.Id) == true)
{
_eventItems[monitoredItem] = new LiveEventItem(handle);
}
@@ -4434,7 +4426,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
if (monitoredItem is ISampledDataChangeMonitoredItem dataItem
&& (!MonitoredNodes.TryGetValue(handle.NodeId, out var current)
- || current.DataChangeMonitoredItems?.Contains(dataItem) != true))
+ || current.DataChangeMonitoredItems?.ContainsKey(dataItem.Id) != true))
{
OnMonitoredItemDeleted(context, handle, dataItem);
@@ -4498,7 +4490,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
MonitoredNodes.Remove(nodeId);
if (monitored.DataChangeMonitoredItems is { Count: > 0 } dataItems)
{
- foreach (var item in dataItems.ToArray())
+ foreach (var item in dataItems.Values.ToArray())
{
TellNodeRemoved(item, now);
if (item is ISampledDataChangeMonitoredItem sampled) _liveItems.TryRemove(sampled, out _);
@@ -4511,7 +4503,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// No component-cache release: an event item never took a reference (the master node manager
// creates it through the EventManager + SubscribeToEvents, not AddNodeToComponentCache), so a
// release here would take one from a DATA item on the same NodeId.
- foreach (var item in eventItems.ToArray()) _eventItems.TryRemove(item, out _);
+ foreach (var item in eventItems.Values.ToArray()) _eventItems.TryRemove(item, out _);
}
}
@@ -4544,7 +4536,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var rootId = _root?.NodeId;
foreach (var nodeId in MonitoredNodes.Keys.ToList())
{
- if (rootId is not null && nodeId == rootId) continue;
+ if (rootId is not null && nodeId == rootId.Value) continue;
TombstoneMonitoredNode(nodeId, now);
}
@@ -4557,7 +4549,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
foreach (var (item, tracked) in _liveItems)
{
- if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue;
+ if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId.Value)) continue;
if (!_liveItems.TryRemove(item, out _)) continue;
Interlocked.Decrement(ref _suppressedItems);
TellNodeRemoved(item, now);
@@ -4566,7 +4558,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
foreach (var (item, tracked) in _eventItems)
{
- if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue;
+ if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId.Value)) continue;
if (!_eventItems.TryRemove(item, out _)) continue;
Interlocked.Decrement(ref _suppressedItems);
// Event items hold no component-cache reference (see TombstoneMonitoredNode).
@@ -4579,7 +4571,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
try
{
item.QueueValue(
- new DataValue(StatusCodes.BadNodeIdUnknown) { SourceTimestamp = now, ServerTimestamp = now },
+ new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown, now, now),
new ServiceResult(StatusCodes.BadNodeIdUnknown),
ignoreFilters: true);
}
@@ -4865,7 +4857,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (_allEventsItems.IsEmpty) return;
var scopes = AclEnforced ? ConditionVisibilityScopes(node) : null;
- var reportingSession = (context as ISessionSystemContext)?.SessionId?.Identifier;
+ var reportingSession = (context as ISessionSystemContext)?.SessionId;
foreach (var item in _allEventsItems.Keys)
{
@@ -4873,7 +4865,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
if (reportingSession is not null
- && item.Session?.Id?.Identifier is { } own
+ && item.Session?.Id is { } own
&& !own.Equals(reportingSession))
{
continue;
@@ -4980,7 +4972,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
GeneralModelChangeEventState modelChange => FilterModelChangeFor(identity, modelChange),
AuditEventState audit => IsAuditDeliverableTo(item)
- && audit.SourceNode?.Value is { IsNullNodeId: false } source
+ && audit.SourceNode?.Value is { IsNull: false } source
&& Permits(identity, OpcUaOperation.Read, source)
? audit
: null,
@@ -5006,25 +4998,25 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
internal GeneralModelChangeEventState? FilterModelChangeFor(
RoleCarryingUserIdentity? identity, GeneralModelChangeEventState e)
{
- var changes = e.Changes?.Value;
- if (changes is not { Length: > 0 }) return e;
+ var changes = e.Changes?.Value ?? default;
+ if (changes.Count == 0) return e;
- var visible = changes
- .Where(c => c.Affected is null || c.Affected.IsNullNodeId || Permits(identity, OpcUaOperation.Browse, c.Affected))
+ var visible = (changes.ToArray() ?? [])
+ .Where(c => c.Affected.IsNull || Permits(identity, OpcUaOperation.Browse, c.Affected))
.ToArray();
- if (visible.Length == changes.Length) return e;
+ if (visible.Length == changes.Count) return e;
if (visible.Length == 0) return null;
// The original Message names the affected node(s), so the copy carries a neutral one.
var copy = new GeneralModelChangeEventState(null);
copy.Initialize(
- SystemContext, source: null, (EventSeverity)e.Severity.Value, new LocalizedText($"{visible.Length} node(s) changed"));
- copy.EventId.Value = e.EventId.Value;
- copy.Time.Value = e.Time.Value;
- copy.ReceiveTime.Value = e.ReceiveTime.Value;
+ SystemContext, source: null, (EventSeverity)e.Severity!.Value, new LocalizedText($"{visible.Length} node(s) changed"));
+ copy.EventId!.Value = e.EventId!.Value;
+ copy.Time!.Value = e.Time!.Value;
+ copy.ReceiveTime!.Value = e.ReceiveTime!.Value;
copy.SetChildValue(SystemContext, BrowseNames.SourceNode, e.SourceNode?.Value ?? ObjectIds.Server, false);
copy.SetChildValue(SystemContext, BrowseNames.SourceName, e.SourceName?.Value ?? "Server", false);
- copy.SetChildValue(SystemContext, BrowseNames.Changes, visible, false);
+ copy.SetChildValue(SystemContext, BrowseNames.Changes, (ArrayOf)visible, false);
return copy;
}
@@ -5305,7 +5297,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Queue BEFORE detaching: the notification has to be in the item's queue while the item is
// still live, or the client is cut off with no explanation.
item.QueueValue(
- new DataValue(StatusCodes.BadUserAccessDenied) { SourceTimestamp = now, ServerTimestamp = now },
+ new DataValue(Variant.Null, StatusCodes.BadUserAccessDenied, now, now),
new ServiceResult(StatusCodes.BadUserAccessDenied),
ignoreFilters: true);
@@ -5451,7 +5443,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (identity is null
|| (identity.ApiKeyId ?? identity.Principal) is not { Length: > 0 } sessionKey
- || nodeId.Identifier is not string { Length: > 0 } identifier)
+ || !nodeId.TryGetValue(out string identifier) || identifier.Length == 0)
{
return _authorizer!.IsAllowedCached(
identity, OpcUaOperation.CreateMonitoredItems, nodeId, realm);
@@ -5560,7 +5552,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
ServerSystemContext context,
ReadRawModifiedDetails details,
TimestampsToReturn timestampsToReturn,
- IList nodesToRead,
+ ArrayOf nodesToRead,
IList results,
IList errors,
List nodesToProcess,
@@ -5590,14 +5582,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// same HistoryRead authorization as a raw read (above); a backend with no trail ⇒ unsupported.
work.Add(() => ServeModifiedPagedAsync(
handle, session, nodesToRead, results, errors,
- details.StartTime, details.EndTime, details.NumValuesPerNode,
+ details.StartTime.ToDateTime(), details.EndTime.ToDateTime(), details.NumValuesPerNode,
ct));
continue;
}
work.Add(() => ServeRawPagedAsync(
handle, session, nodesToRead, results, errors,
- details.StartTime, details.EndTime, details.NumValuesPerNode,
+ details.StartTime.ToDateTime(), details.EndTime.ToDateTime(), details.NumValuesPerNode,
ct, details.ReturnBounds && HonorReturnBounds));
}
@@ -5611,7 +5603,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
ServerSystemContext context,
ReadProcessedDetails details,
TimestampsToReturn timestampsToReturn,
- IList nodesToRead,
+ ArrayOf nodesToRead,
IList results,
IList errors,
List nodesToProcess,
@@ -5657,8 +5649,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var agg = aggregate.Value;
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
tagname,
- details.StartTime,
- details.EndTime,
+ details.StartTime.ToDateTime(),
+ details.EndTime.ToDateTime(),
interval,
agg,
token), ct));
@@ -5673,14 +5665,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
ServerSystemContext context,
ReadAtTimeDetails details,
TimestampsToReturn timestampsToReturn,
- IList nodesToRead,
+ ArrayOf nodesToRead,
IList results,
IList errors,
List nodesToProcess,
IDictionary cache)
{
// Snapshot the requested timestamps once — the same list is read for every node.
- var timestamps = details.ReqTimes?.ToList() ?? new List();
+ var timestamps = (details.ReqTimes.ToArray() ?? []).Select(t => t.ToDateTime()).ToList();
nodesToProcess = AuthorizeHistoryRead(context, nodesToProcess, results, errors);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
@@ -5716,7 +5708,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
HistoryReadDetails details,
TimestampsToReturn timestampsToReturn,
bool releaseContinuationPoints,
- IList nodesToRead,
+ ArrayOf nodesToRead,
IList results,
IList errors,
List nodesToProcess,
@@ -5758,7 +5750,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
ServerSystemContext context,
ReadEventDetails details,
TimestampsToReturn timestampsToReturn,
- IList nodesToRead,
+ ArrayOf nodesToRead,
IList results,
IList errors,
List nodesToProcess,
@@ -5766,7 +5758,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// Snapshot the select clauses once — the same filter projects every node's events — and the where-clause, which
// is evaluated per event against the same projection (an OfType through the server's type tree).
- var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
+ var selectClauses = details.Filter?.SelectClauses ?? new List();
var where = details.Filter?.WhereClause is { Elements.Count: > 0 } w ? w : null;
var filterContext = where is null ? null : new FilterContext(Server.NamespaceUris, Server.TypeTree, context.PreferredLocales, Server.Telemetry);
var session = context.OperationContext?.Session;
@@ -5820,10 +5812,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
NodeHandle handle,
string? sourceName,
ReadEventDetails details,
- SimpleAttributeOperandCollection selectClauses,
+ ArrayOf selectClauses,
ContentFilter? where,
FilterContext? filterContext,
- IList nodesToRead,
+ ArrayOf nodesToRead,
ISession? session,
IList results,
IList errors,
@@ -5835,8 +5827,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var source = _historianDataSource;
try
{
- var startUtc = details.StartTime;
- var endUtc = details.EndTime;
+ var startUtc = details.StartTime.ToDateTime();
+ var endUtc = details.EndTime.ToDateTime();
var numValuesPerNode = details.NumValuesPerNode;
var boundarySkip = 0;
var walkHint = default(ReverseWalkHint); // a walked-back read's width + proven-empty stretch
@@ -5846,7 +5838,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// Single-use take. A miss (unknown / evicted / malformed / a RAW cursor) ⇒ BadContinuationPointInvalid
// for THIS node only.
- var state = _historyContinuationStore.TryTake(session, inboundCp!);
+ var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray());
// A point is bound to the node that issued it: another node's source name is a miss too.
if (state is null || state.Kind != HistoryCursorKind.Events || state.Tagname != (sourceName ?? string.Empty))
{
@@ -6030,7 +6022,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
StatusCode = historyEvent.Events.Count == 0 && outboundCp is null ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyEvent),
// Null when the page was complete, or when no session is available to hold the cursor.
- ContinuationPoint = outboundCp,
+ ContinuationPoint = outboundCp is null ? default : ByteString.From(outboundCp),
};
errors[handle.Index] = ServiceResult.Good;
}
@@ -6135,7 +6127,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
protected override void HistoryUpdateData(
ServerSystemContext context,
- IList nodesToUpdate,
+ ArrayOf nodesToUpdate,
IList results,
IList errors,
List nodesToProcess,
@@ -6155,7 +6147,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
default: return null; // Remove (and anything unknown) ⇒ unsupported
}
- var items = (IList?)details.UpdateValues ?? Array.Empty();
+ var items = details.UpdateValues.ToArray() ?? [];
return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, latest, who, ct);
});
}
@@ -6163,7 +6155,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
protected override void HistoryDeleteRawModified(
ServerSystemContext context,
- IList nodesToUpdate,
+ ArrayOf nodesToUpdate,
IList results,
IList errors,
List nodesToProcess,
@@ -6175,8 +6167,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Deleting the modified-value trail itself (Part 11 IsDeleteModified) is not supported: the trail is the
// audit record of every change and is aged out only by retention.
if (details.IsDeleteModified) return null;
- var start = details.StartTime;
- var end = details.EndTime;
+ var start = details.StartTime.ToDateTime();
+ var end = details.EndTime.ToDateTime();
// Part 11 requires both bounds of a raw delete; DateTime.MinValue is the wire's "unspecified". Refuse
// it here rather than let a backend read MinValue as "from the beginning of time".
if (start == DateTime.MinValue || end == DateTime.MinValue)
@@ -6192,7 +6184,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
protected override void HistoryDeleteAtTime(
ServerSystemContext context,
- IList nodesToUpdate,
+ ArrayOf nodesToUpdate,
IList results,
IList errors,
List nodesToProcess,
@@ -6200,7 +6192,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
ServeHistoryUpdate(context, nodesToProcess, results, errors, handle =>
{
- var times = nodesToUpdate[handle.Index].ReqTimes?.ToList() ?? new List();
+ var times = (nodesToUpdate[handle.Index].ReqTimes.ToArray() ?? []).Select(t => t.ToDateTime()).ToList();
return async (updater, tagname, who, ct) =>
{
var codes = times.Count == 0
@@ -6212,7 +6204,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return new HistoryUpdateResult
{
StatusCode = StatusCodes.Good,
- OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))),
+ OperationResults = new List(codes.Select(c => new StatusCode(c))),
};
};
});
@@ -6273,26 +6265,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
- if (item is null)
+ if (item.SourceTimestamp == DateTime.MinValue)
{
- codes[i] = StatusCodes.BadInvalidArgument;
+ codes[i] = StatusCodes.BadInvalidTimestamp.Code;
}
- else if (item.SourceTimestamp == DateTime.MinValue)
- {
- codes[i] = StatusCodes.BadInvalidTimestamp;
- }
- else if (latest is { } bound && ToUtc(item.SourceTimestamp) > bound)
+ else if (latest is { } bound && ToUtc(item.SourceTimestamp.ToDateTime()) > bound)
{
// A far-future history edit is refused before any backend sees it.
- codes[i] = StatusCodes.BadOutOfRange;
+ codes[i] = StatusCodes.BadOutOfRange.Code;
}
- else if (!HistorianValue.TryFromObject(item.Value, out var value))
+ else if (!HistorianValue.TryFromObject(SdkValueBoundary.ToClr(item.WrappedValue), out var value))
{
- codes[i] = StatusCodes.BadTypeMismatch;
+ codes[i] = StatusCodes.BadTypeMismatch.Code;
}
else
{
- toSend.Add(new HistorizationValue(ToUtc(item.SourceTimestamp), value, item.StatusCode.Code));
+ toSend.Add(new HistorizationValue(ToUtc(item.SourceTimestamp.ToDateTime()), value, item.StatusCode.Code));
sentIndex.Add(i);
}
}
@@ -6311,7 +6299,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return new HistoryUpdateResult
{
StatusCode = StatusCodes.Good,
- OperationResults = new StatusCodeCollection(codes.Select(c => new StatusCode(c))),
+ OperationResults = new List(codes.Select(c => new StatusCode(c))),
};
}
@@ -6362,8 +6350,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// and after planning. Off-Primary every servable node is refused BadInvalidState and nothing reaches the updater.
if (HistoryUpdatePrimaryGate is { } primary && !primary())
{
- var refusal = new ServiceResult(StatusCodes.BadInvalidState,
- new LocalizedText("This server is not the redundancy Primary; update history on the Primary (the server with the higher ServiceLevel)."));
+ var refusal = new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText("This server is not the redundancy Primary; update history on the Primary (the server with the higher ServiceLevel)."));
foreach (var (handle, _, _) in planned)
{
errors[handle.Index] = refusal;
@@ -6485,16 +6472,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The context is evaluated in.
/// The populated SDK .
private HistoryEvent ProjectEvents(
- IReadOnlyList events, SimpleAttributeOperandCollection selectClauses,
+ IReadOnlyList events, ArrayOf selectClauses,
ContentFilter? where, FilterContext? filterContext)
{
- var fieldLists = new HistoryEventFieldListCollection(events.Count);
+ var fieldLists = new List(events.Count);
foreach (var evt in events)
{
var target = new HistoricalEventFilterTarget(this, evt);
if (where is not null && filterContext is not null && !where.Evaluate(filterContext, target)) continue;
- var fields = new VariantCollection(selectClauses.Count);
+ var fields = new List(selectClauses.Count);
foreach (var operand in selectClauses)
{
fields.Add(target.Project(operand));
@@ -6522,13 +6509,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
public bool IsTypeOf(IFilterContext context, NodeId typeDefinitionId) =>
EventType == typeDefinitionId || context.TypeTree.IsTypeOf(EventType, typeDefinitionId);
- public object? GetAttributeValue(IFilterContext context, NodeId typeDefinitionId, IList relativePath,
+ public Variant GetAttributeValue(IFilterContext context, NodeId typeDefinitionId, ArrayOf relativePath,
uint attributeId, NumericRange indexRange)
{
- if (attributeId != Attributes.Value || relativePath is null || relativePath.Count == 0) return null;
- var operand = new SimpleAttributeOperand { TypeDefinitionId = typeDefinitionId, AttributeId = attributeId };
- foreach (var name in relativePath) operand.BrowsePath.Add(name);
- return Project(operand).Value;
+ if (attributeId != Attributes.Value || relativePath.Count == 0) return Variant.Null;
+ var operand = new SimpleAttributeOperand { TypeDefinitionId = typeDefinitionId, AttributeId = attributeId, BrowsePath = relativePath };
+ return Project(operand);
}
}
@@ -6609,7 +6595,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private static string? LeafFieldName(SimpleAttributeOperand operand)
{
var path = operand.BrowsePath;
- if (path is null || path.Count == 0) return null;
+ if (path.Count == 0) return null;
return path[^1].Name;
}
@@ -6808,7 +6794,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Single-shot arms (Processed / AtTime) never page — the backend returns the complete
// result in one read (no client count cap to detect a "full page" against), so no
// continuation point. Raw pages via ServeRawPaged, not this helper.
- ContinuationPoint = null,
+ ContinuationPoint = default,
};
errors[handle.Index] = ServiceResult.Good;
}
@@ -6852,7 +6838,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// synthesised time-based:
///
/// - Fresh read (no inbound continuation point): read the window from
- /// details.StartTime to capped at
+ /// details.StartTime.ToDateTime() to capped at
/// + 1 — one lookahead row. If the lookahead row came
/// back, return the first N, store a resume cursor and emit a continuation point; otherwise the page is
/// the last and carries none (so a total that is an exact multiple of N ends on a full page, not on an
@@ -6889,7 +6875,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private async Task ServeRawPagedAsync(
NodeHandle handle,
ISession? session,
- IList nodesToRead,
+ ArrayOf nodesToRead,
IList results,
IList errors,
DateTime startTimeUtc,
@@ -6913,7 +6899,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒
// BadContinuationPointInvalid for THIS node.
- var state = _historyContinuationStore.TryTake(session, inboundCp);
+ var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray());
if (state is null || state.Kind != HistoryCursorKind.Raw
|| !TryGetHistorizedTagname(handle.NodeId, out var boundTag) || boundTag != state.Tagname)
{
@@ -7024,7 +7010,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples(onlyBound)),
- ContinuationPoint = afterCp,
+ ContinuationPoint = afterCp is null ? default : ByteString.From(afterCp),
};
errors[handle.Index] = ServiceResult.Good;
return;
@@ -7055,7 +7041,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples([startBound])),
- ContinuationPoint = boundOnlyCp,
+ ContinuationPoint = boundOnlyCp is null ? default : ByteString.From(boundOnlyCp),
};
errors[handle.Index] = ServiceResult.Good;
return;
@@ -7068,7 +7054,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
StatusCode = StatusCodes.GoodNoData,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples([])),
- ContinuationPoint = null,
+ ContinuationPoint = default,
};
errors[handle.Index] = ServiceResult.Good;
return;
@@ -7250,7 +7236,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
StatusCode = slice.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples(slice)),
- ContinuationPoint = clusterCp,
+ ContinuationPoint = clusterCp is null ? default : ByteString.From(clusterCp),
};
errors[handle.Index] = ServiceResult.Good;
return;
@@ -7319,7 +7305,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// genuinely drained — never silent data loss.
StatusCode = samples.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyData),
- ContinuationPoint = outboundCp,
+ ContinuationPoint = outboundCp is null ? default : ByteString.From(outboundCp),
};
errors[handle.Index] = ServiceResult.Good;
}
@@ -7368,7 +7354,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private async Task ServeModifiedPagedAsync(
NodeHandle handle,
ISession? session,
- IList nodesToRead,
+ ArrayOf nodesToRead,
IList results,
IList errors,
DateTime startTimeUtc,
@@ -7390,7 +7376,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
HistoryModifiedCursor? resumeAfter = null;
if (inboundCp is { Length: > 0 })
{
- var state = _historyContinuationStore.TryTake(session, inboundCp);
+ var state = _historyContinuationStore.TryTake(session, inboundCp.ToArray());
if (state is null || state.Kind != HistoryCursorKind.Modified || state.Tagname != tagname)
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
@@ -7430,7 +7416,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
StatusCode = page.Values.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(data),
- ContinuationPoint = outboundCp,
+ ContinuationPoint = outboundCp is null ? default : ByteString.From(outboundCp),
};
errors[handle.Index] = ServiceResult.Good;
}
@@ -7462,8 +7448,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The populated .
internal static HistoryModifiedData ToHistoryModifiedData(IReadOnlyList values)
{
- var dataValues = new DataValueCollection(values.Count);
- var infos = new ModificationInfoCollection(values.Count);
+ var dataValues = new List(values.Count);
+ var infos = new List(values.Count);
foreach (var m in values)
{
dataValues.Add(ToSdkDataValue(m.Value));
@@ -7481,7 +7467,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
protected override void HistoryReleaseContinuationPoints(
ServerSystemContext context,
- IList nodesToRead,
+ ArrayOf nodesToRead,
IList errors,
List nodesToProcess,
IDictionary cache)
@@ -7492,7 +7478,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var cp = nodesToRead[handle.Index].ContinuationPoint;
if (cp is { Length: > 0 })
{
- _historyContinuationStore.Release(session, cp);
+ _historyContinuationStore.Release(session, cp.ToArray());
}
errors[handle.Index] = ServiceResult.Good;
@@ -7500,7 +7486,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
/// Transport failures are retryable; unsupported operations retain their distinct status.
- private static uint HistoryReadFailureStatus(Exception error)
+ private static StatusCode HistoryReadFailureStatus(Exception error)
{
for (Exception? ex = error; ex is not null; ex = ex.InnerException)
if (ex is System.IO.IOException or System.Net.Sockets.SocketException or System.Net.Http.HttpRequestException or TimeoutException)
@@ -7514,31 +7500,29 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
foreach (var handle in handles)
{
if (AtTimeValueType(handle.NodeId) is not { } type || results[handle.Index]?.HistoryData is not { } body) continue;
- var values = ExtensionObject.ToEncodeable(body) switch
+ if (body.IsNull || ExtensionObject.ToEncodeable(body) is not HistoryData data) continue;
+ var values = data.DataValues.ToArray() ?? [];
+ for (var i = 0; i < values.Length; i++)
{
- HistoryData data => data.DataValues,
- _ => null,
- };
- if (values is null) continue;
- foreach (var value in values)
- {
- if (value.Value is null) continue;
+ var value = values[i];
+ var payload = SdkValueBoundary.ToClr(value.WrappedValue);
+ if (payload is null) continue;
try
{
// A gateway number cannot reconstruct a DateTime or a String which was never stored.
- if ((type == TypeCode.DateTime && value.Value is not DateTime)
- || (type == TypeCode.String && value.Value is not string)) throw new InvalidCastException();
- var converted = Convert.ChangeType(value.Value, type, System.Globalization.CultureInfo.InvariantCulture);
- if (converted is float f && float.IsInfinity(f) && value.Value is double d && double.IsFinite(d))
+ if ((type == TypeCode.DateTime && payload is not DateTime)
+ || (type == TypeCode.String && payload is not string)) throw new InvalidCastException();
+ var converted = Convert.ChangeType(payload, type, System.Globalization.CultureInfo.InvariantCulture);
+ if (converted is float f && float.IsInfinity(f) && payload is double d && double.IsFinite(d))
throw new OverflowException();
- value.WrappedValue = new Variant(converted);
+ values[i] = value.WithWrappedValue(SdkValueBoundary.FromClr(converted));
}
catch (Exception ex) when (ex is InvalidCastException or OverflowException or FormatException)
{
- value.WrappedValue = Variant.Null;
- value.StatusCode = StatusCodes.BadTypeMismatch;
+ values[i] = value.WithWrappedValue(Variant.Null).WithStatus(StatusCodes.BadTypeMismatch);
}
}
+ data.DataValues = values;
}
}
@@ -7548,7 +7532,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The populated SDK .
private static HistoryData ToHistoryDataFromSamples(IReadOnlyList samples)
{
- var values = new DataValueCollection(samples.Count);
+ var values = new List(samples.Count);
foreach (var sample in samples) values.Add(ToSdkDataValue(sample));
return new HistoryData { DataValues = values };
}
@@ -7563,7 +7547,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// The populated SDK .
private static HistoryData ToHistoryData(HistorianRead sourceResult)
{
- var values = new DataValueCollection(sourceResult.Samples.Count);
+ var values = new List(sourceResult.Samples.Count);
foreach (var sample in sourceResult.Samples)
{
values.Add(ToSdkDataValue(sample));
@@ -7576,13 +7560,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// , mirroring value / status code / source + server timestamps.
/// The source sample.
/// The equivalent SDK data value.
- private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new()
- {
- WrappedValue = new Variant(snapshot.Value),
- StatusCode = new StatusCode(snapshot.StatusCode),
- SourceTimestamp = snapshot.SourceTimestampUtc ?? DateTime.MinValue,
- ServerTimestamp = snapshot.ServerTimestampUtc,
- };
+ private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new(
+ SdkValueBoundary.FromClr(snapshot.Value),
+ new StatusCode(snapshot.StatusCode),
+ snapshot.SourceTimestampUtc ?? DateTime.MinValue,
+ snapshot.ServerTimestampUtc);
///
public override void CreateAddressSpace(IDictionary> externalReferences)
@@ -7600,7 +7582,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
NodeId = new NodeId(V3NodeIds.SharedRootFolderId, NamespaceIndex),
BrowseName = new QualifiedName(V3NodeIds.SharedRootFolderId, NamespaceIndex),
- DisplayName = V3NodeIds.SharedRootFolderId,
+ DisplayName = new LocalizedText(V3NodeIds.SharedRootFolderId),
EventNotifier = EventNotifiers.None,
TypeDefinitionId = ObjectTypeIds.FolderType,
};
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
index 058cc702..c3ff4f08 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaSdkServer.cs
@@ -22,17 +22,40 @@ public sealed class OtOpcUaSdkServer : StandardServer
/// Initializes a new instance of the class.
/// Logger handed to the created ;
/// null leaves it on a no-op logger.
- public OtOpcUaSdkServer(ILogger? nodeManagerLogger = null) => _nodeManagerLogger = nodeManagerLogger;
+ public OtOpcUaSdkServer(ILogger? nodeManagerLogger = null)
+ : base(DefaultTelemetry.Create(_ => { })) => _nodeManagerLogger = nodeManagerLogger;
- ///
- /// The client's remote IP endpoint for the secure channel , or null when it
- /// cannot be resolved (lmxopcua#751). Best effort — see ; the SDK
- /// exposes the listeners only to a subclass, which is why this lives here.
- ///
- /// The global secure channel id a request arrived on.
- /// The remote endpoint, or null.
- public System.Net.IPEndPoint? TryResolveRemoteEndpoint(string? secureChannelId) =>
- SecureChannelRemoteEndpoint.Resolve(TransportListeners, secureChannelId);
+ private readonly AsyncLocal _activationPeer = new();
+
+ /// The transport-observed peer of the current activation request, when available.
+ internal System.Net.IPAddress? ActivationPeerAddress => _activationPeer.Value;
+
+ ///
+ public override async ValueTask ActivateSessionAsync(
+ SecureChannelContext secureChannelContext,
+ RequestHeader? requestHeader,
+ SignatureData? clientSignature,
+ ArrayOf clientSoftwareCertificates,
+ ArrayOf localeIds,
+ ExtensionObject userIdentityToken,
+ SignatureData? userTokenSignature,
+ RequestLifetime requestLifetime)
+ {
+ // AuthenticationContext does not carry the request peer in 2.0.1. Keep the public
+ // transport evidence in this activation's async flow, including reactivation on a new channel.
+ var previous = _activationPeer.Value;
+ _activationPeer.Value = secureChannelContext.PeerAddress;
+ try
+ {
+ return await base.ActivateSessionAsync(secureChannelContext, requestHeader, clientSignature,
+ clientSoftwareCertificates, localeIds, userIdentityToken, userTokenSignature, requestLifetime)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ _activationPeer.Value = previous;
+ }
+ }
/// The custom node manager once StartAsync has called
/// . Null until the SDK has bootstrapped.
@@ -230,15 +253,15 @@ public sealed class OtOpcUaSdkServer : StandardServer
// this point that is the Null default (no history aggregates); the Host's SetHistorianDataSource
// re-syncs to the configured backend's set right after start.
///
- protected override AggregateManager CreateAggregateManager(
- IServerInternal server, ApplicationConfiguration configuration)
+ protected override async ValueTask CreateAggregateManagerAsync(
+ IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default)
{
- var manager = base.CreateAggregateManager(server, configuration);
+ var manager = await base.CreateAggregateManagerAsync(server, configuration, cancellationToken).ConfigureAwait(false);
// Pin the SDK manager's default AggregateConfiguration (what live AggregateFilter subscriptions compute under
// and what the server advertises) to OUR defaults. Opc.Ua.Server 1.5.378.182 flipped the SDK default of
// TreatUncertainAsBad to true; history-read aggregates (Sql.Common evaluators) are fixed at false, so the live
// and historical paths must agree and stay behaviour-neutral across the SDK hop (lmxopcua#782).
- var defaults = manager.GetDefaultConfiguration(null) ?? new AggregateConfiguration();
+ var defaults = manager.GetDefaultConfiguration(NodeId.Null) ?? new AggregateConfiguration();
defaults.TreatUncertainAsBad = ServerAggregateConfiguration.TreatUncertainAsBad;
defaults.PercentDataBad = ServerAggregateConfiguration.PercentDataBad;
defaults.PercentDataGood = ServerAggregateConfiguration.PercentDataGood;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs
index de984308..0ea04b88 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// Uses (a ) and
/// its child variable, which the SDK populates
/// automatically during initialization. Writes are
-/// guarded by so concurrent diagnostics scans
+/// guarded by so concurrent diagnostics scans
/// from the SDK don't fight with our update.
///
public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
@@ -44,13 +44,13 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
try
{
- lock (_serverInternal.DiagnosticsLock)
+ _serverInternal.UpdateServerDiagnostics(_ =>
{
node.Value = serviceLevel;
node.Timestamp = DateTime.UtcNow;
node.StatusCode = StatusCodes.Good;
node.ClearChangeMasks(_serverInternal.DefaultSystemContext, includeChildren: false);
- }
+ });
}
catch (Exception ex)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkValueBoundary.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkValueBoundary.cs
new file mode 100644
index 00000000..9aeb8e26
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkValueBoundary.cs
@@ -0,0 +1,34 @@
+using Opc.Ua;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
+
+/// Copies supported SDK payloads at the server's CLR value boundary.
+internal static class SdkValueBoundary
+{
+ /// Build a detached Variant for an existing supported scalar or one-dimensional array.
+ internal static Variant FromClr(object? value, bool byteArray = false)
+ {
+ if (value is null) return Variant.Null;
+ if (value is Array array)
+ {
+ if (array.Rank != 1) throw new ArgumentException("Only one-dimensional values are supported.", nameof(value));
+ value = array.Clone();
+ }
+ if (byteArray && value is byte[] bytes) return Variant.From((ArrayOf)bytes);
+ if (VariantHelper.TryCastFrom(value, out var variant)) return variant;
+ throw new ArgumentException("Unsupported OPC UA value type.", nameof(value));
+ }
+
+ /// Restore existing CLR payload types and detach mutable arrays.
+ internal static object? ToClr(Variant value) => value.AsBoxedObject(Variant.BoxingBehavior.Legacy) switch
+ {
+ ByteString bytes => bytes.IsNull ? null : bytes.ToArray(),
+ DateTimeUtc time => time.ToDateTime(),
+ Uuid id => (Guid)id,
+ DateTimeUtc[] times => times.Select(x => x.ToDateTime()).ToArray(),
+ Uuid[] ids => ids.Select(x => (Guid)x).ToArray(),
+ ByteString[] strings => strings.Select(x => x.IsNull ? null : x.ToArray()).ToArray(),
+ Array array => array.Clone(),
+ var scalar => scalar,
+ };
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs
index 2da221ad..20dbfb9d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/DataPlaneAuthorizer.cs
@@ -297,7 +297,7 @@ public sealed class DataPlaneAuthorizer
// The s= identifier, read once: it is both the cache key and the raw material the scope is split
// from, and pulling it up here absorbs what used to be TryBuildScope's own guard. A NodeId that
// carries no string addresses no ACL scope at all, so it can never be answered from the cache.
- if (nodeId?.Identifier is not string identifier || identifier.Length == 0)
+ if (!nodeId.TryGetValue(out string identifier) || identifier.Length == 0)
{
return Deny(operation, nodeId, identity.Principal,
"the node id carries no string identifier, so it addresses no ACL scope", cached);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs
deleted file mode 100644
index 384af2dc..00000000
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/SecureChannelRemoteEndpoint.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-using System.Collections;
-using System.Net;
-using System.Reflection;
-using Opc.Ua;
-
-namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
-
-///
-/// Resolves the client's remote IP endpoint for a secure channel, so session activation can account
-/// pre-authentication API-key misses per client (lmxopcua#751).
-///
-///
-///
-/// ⚠️ The OPC UA SDK (1.5.378) does not expose the client address on any public surface — not
-/// on ISession, SecureChannelContext, ImpersonateEventArgs nor the transport
-/// listener. The TCP listener holds it privately: TcpTransportListener.m_channels (channel id
-/// → channel) → the channel's non-public Socket → the public
-/// IMessageSocket.RemoteEndpoint. This class walks that path by reflection.
-///
-///
-/// It is strictly best effort and fail-soft: any missing member (an SDK upgrade renamed it), an
-/// unknown channel or a closed socket yields , and a caller treats null as
-/// "address unknown" — the per-endpoint budget is skipped and the process-wide one still applies. It
-/// never throws. An over-the-wire test (RemoteEndpointResolutionTests) pins that it resolves on
-/// the SDK version in use, so an upgrade that breaks the path fails a test instead of silently
-/// degrading to process-wide accounting.
-///
-///
-public static class SecureChannelRemoteEndpoint
-{
- private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
-
- ///
- /// Finds the remote endpoint of the secure channel among
- /// .
- ///
- /// The server's transport listeners.
- /// The global secure channel id ({ListenerId}-{ChannelId}).
- /// The client's IP endpoint (IPv4-mapped addresses normalised to IPv4), or null when unknown.
- public static IPEndPoint? Resolve(IEnumerable? listeners, string? secureChannelId)
- {
- if (listeners is null || string.IsNullOrEmpty(secureChannelId)) return null;
- try
- {
- foreach (var listener in listeners)
- {
- if (listener is null) continue;
- var listenerId = listener.ListenerId;
- if (string.IsNullOrEmpty(listenerId)
- || !secureChannelId.StartsWith(listenerId + "-", StringComparison.Ordinal)
- || !uint.TryParse(secureChannelId.AsSpan(listenerId.Length + 1), out var channelId))
- {
- continue;
- }
-
- if (FindChannel(listener, channelId) is { } channel && RemoteEndpointOf(channel) is { } endpoint)
- return endpoint;
- }
- }
- catch
- {
- // Fail-soft by contract: the caller falls back to process-wide accounting.
- }
-
- return null;
- }
-
- /// The listener's channel with id , read from its private channel map.
- private static object? FindChannel(ITransportListener listener, uint channelId)
- {
- var field = FindField(listener.GetType(), "m_channels");
- if (field?.GetValue(listener) is not IDictionary channels) return null;
- return channels.Contains(channelId) ? channels[channelId] : null;
- }
-
- /// The channel's socket's remote endpoint, as an .
- private static IPEndPoint? RemoteEndpointOf(object channel)
- {
- var socketProperty = FindProperty(channel.GetType(), "Socket");
- var socket = socketProperty?.GetValue(channel);
- if (socket is null) return null;
- var remote = FindProperty(socket.GetType(), "RemoteEndpoint")?.GetValue(socket) as IPEndPoint;
- if (remote is null) return null;
- return remote.Address.IsIPv4MappedToIPv6 ? new IPEndPoint(remote.Address.MapToIPv4(), remote.Port) : remote;
- }
-
- private static FieldInfo? FindField(Type? type, string name)
- {
- for (; type is not null; type = type.BaseType)
- {
- if (type.GetField(name, Instance | BindingFlags.DeclaredOnly) is { } field) return field;
- }
-
- return null;
- }
-
- private static PropertyInfo? FindProperty(Type? type, string name)
- {
- for (; type is not null; type = type.BaseType)
- {
- if (type.GetProperty(name, Instance | BindingFlags.DeclaredOnly) is { } property) return property;
- }
-
- return null;
- }
-}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj
index ccc65441..0e24ce27 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj
@@ -5,13 +5,7 @@
-
-