Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
T
Joseph Doherty 3cf3576c75 fix(v3-batch4-wp4): alarm fan-out hardening — event-delivery test, resolution-failure meter, teardown guards (Wave C review M1/M2/M3/L1/L3)
M1 — over-the-wire event-delivery proof (new NativeAlarmMultiNotifierEventDeliveryTests
in OpcUaServer.IntegrationTests): boots the real server, wires one condition to two
equipment folders, fires ONE transition, and asserts a Server-object subscriber gets
EXACTLY ONE event (the shared-InstanceStateSnapshot queue dedup), plus overlapping
Server + equipment-folder subscribers each get exactly one copy. Captures via the
subscription FastEventCallback keyed by ClientHandle (the per-item Notification/DequeueEvents
path delivers nothing for these conditions in the SDK client — the working capture is the
fast callback).

M2 — resolution-failure signal + path-agreement tripwire (AddressSpaceApplier): when a
native alarm HAS ReferencingEquipmentPaths but NONE resolve to an equipment folder, count
it as a failed node (degraded-apply meter) + LogWarning instead of a silent skip; documented
the DisplayName==Name coupling as a binding invariant. Tests:
Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier (real
composer output → applier inversion) + ..._unresolvable_referencing_equipment_counts_as_failed.

M3 — WireAlarmNotifiers is now a RECONCILE (unwires a folder dropped from the supplied set,
bidirectionally) so a future surgical ChangedRawTags path can't leave a de-referenced
equipment receiving the alarm; binding guard comment added on the classifier's
ChangedRawTags→Rebuild branch. Test: WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set.

L1 — MaterialiseAlarmCondition's kind-swap drop-and-recreate now calls
UnwireAlarmNotifiers(conditionKey) before discarding the old instance (teardown symmetry).

L3 — BuildEquipmentIdByFolderPath LogWarnings on a duplicate Area/Line/Name collision
(defense-in-depth; UNS uniqueness prevents it).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 12:52:34 -04:00

3219 lines
201 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
// The SDK's HistoryRead service result (the value the override fills + hands back) and the historian
// data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous:
// the SDK result stays unqualified as the dominant name in the override; the source DTO is HistorianRead.
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Custom OPC UA <see cref="CustomNodeManager2"/> that owns the writable address space for
/// the OtOpcUa server. Variable nodes are created lazily on first <see cref="WriteValue"/>
/// under the manager's namespace; subsequent writes update the existing node's Value +
/// StatusCode + SourceTimestamp and notify subscribed clients via the standard
/// <c>ClearChangeMasks</c> path.
///
/// This is the F10b production wiring behind the v2 <see cref="IOpcUaAddressSpaceSink"/>
/// seam — once a <see cref="SdkAddressSpaceSink"/> is bound, OpcUaPublishActor's writes
/// materialise as real OPC UA Variable updates that clients can browse + subscribe to.
///
/// Node-id encoding uses the manager's default namespace + the caller-supplied string id
/// as the identifier portion (e.g. <c>"ns=2;s=eq-1/temp"</c>). Beyond lazily-created flat
/// variables, the manager also materialises the full UNS Area/Line/Equipment folder hierarchy
/// (<see cref="EnsureFolder"/>), typed equipment-tag variables with the correct built-in
/// <c>DataType</c> / array shape / access levels (<see cref="EnsureVariable"/>), historized
/// nodes (the <c>HistoryRead</c> access bit + HistoryRead overrides), and real Part 9
/// <see cref="AlarmConditionState"/> nodes (<see cref="MaterialiseAlarmCondition"/>). The
/// <c>AddressSpaceApplier</c> drives these passes from the composed deployment artifact;
/// the legacy <c>EquipmentNodeWalker</c> server-side integration was retired in favour of the
/// (composer → applier → sink → node-manager) chain.
/// </summary>
public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
/// <summary>v3 dual-namespace: both the Raw (device tree) and UNS (equipment tree) namespaces are
/// registered with the SDK so nodes can be minted in either. <see cref="V3NodeIds.RawNamespaceUri"/> is
/// registered FIRST (namespace index <see cref="NamespaceIndexes"/>[0]) and
/// <see cref="V3NodeIds.UnsNamespaceUri"/> SECOND ([1]); <see cref="NamespaceIndexForRealm"/> maps a realm
/// to its index. Which realm a node belongs to travels with each sink call as an
/// <see cref="AddressSpaceRealm"/> — the namespace is never parsed back out of the id string.</summary>
private static readonly string[] RegisteredNamespaceUris = { V3NodeIds.RawNamespaceUri, V3NodeIds.UnsNamespaceUri };
/// <summary>Transitional alias kept so the pre-v3 single-namespace integration tests
/// (<c>SubscriptionSurvivalTests</c>) still resolve a namespace index. It points at
/// <see cref="V3NodeIds.UnsNamespaceUri"/> — the same realm as the transitional default of every sink
/// method — so those tests keep resolving the namespace the applier's default-realm nodes live in. The two
/// v3 URIs are the real namespaces; this alias is removed once those tests target Raw/UNS explicitly.</summary>
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, AlarmConditionState> _alarmConditions = new(StringComparer.Ordinal);
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> node ids materialised as NATIVE
/// (driver-fed, e.g. Galaxy equipment-tag alarms) rather than scripted. A later task routes a native
/// condition's inbound Acknowledge to the driver instead of the scripted engine, so the node manager
/// must know which conditions are native. Maintained in lock-step with <see cref="_alarmConditions"/>:
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
private readonly HashSet<string> _nativeAlarmNodeIds = new(StringComparer.Ordinal);
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<string, string> _historizedTagnames = new(StringComparer.Ordinal);
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
/// <summary>v3 Batch 4 (WP4 multi-notifier native alarms): tracks, per materialised native-alarm condition,
/// the equipment folders wired as EXTRA event-notifier roots for it (via <see cref="WireAlarmNotifiers"/>).
/// A native alarm is a single condition at the raw tag (ConditionId = RawPath); its single
/// <c>ReportEvent</c> fans one event to every referencing equipment's UNS folder through the SDK notifier
/// list (never re-reported per root). Keyed by the condition's full (namespace-qualified) NodeId string
/// (matches <see cref="_alarmConditions"/>' keys). On rebuild / condition-removal / equipment-subtree-removal
/// each wired pair is torn down bidirectionally (<c>RemoveNotifier(bidirectional:true)</c>) so an
/// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
private readonly Dictionary<string, AlarmNotifierWiring> _alarmNotifierWiring = new(StringComparer.Ordinal);
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
/// <see cref="EnsureFolderIsEventNotifier"/> only when a real historian is wired at promotion time,
/// and the <see cref="HistoryReadEvents"/> override resolves an inbound request's notifier NodeId
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<string, string> _eventNotifierSources = new(StringComparer.Ordinal);
private FolderState? _root;
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
/// <param name="server">The OPC UA server instance.</param>
/// <param name="configuration">The application configuration.</param>
public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: base(server, configuration, RegisteredNamespaceUris)
{
// SystemContext is initialised by the base ctor; both namespace URIs are now registered and their
// indexes captured (in the order passed) in NamespaceIndexes.
}
/// <summary>Resolve a realm to the SDK namespace index its nodes are minted under. Raw is registered
/// first (<see cref="NamespaceIndexes"/>[0]), UNS second ([1]) — see <see cref="RegisteredNamespaceUris"/>.</summary>
/// <param name="realm">The address-space realm.</param>
/// <returns>The namespace index for the realm.</returns>
internal ushort NamespaceIndexForRealm(AddressSpaceRealm realm) => realm switch
{
AddressSpaceRealm.Raw => NamespaceIndexes[0],
AddressSpaceRealm.Uns => NamespaceIndexes[1],
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
};
/// <summary>Recover the realm of an already-resolved SDK <see cref="NodeId"/> from its namespace index —
/// used at the SDK-facing seams (inbound write, HistoryRead) where the request carries a full NodeId.
/// Any index that is not the Raw namespace is treated as UNS (the two registered indexes are the only
/// ones this manager mints into).</summary>
/// <param name="nodeId">The resolved SDK node id.</param>
/// <returns>The realm the node belongs to.</returns>
private AddressSpaceRealm RealmOf(NodeId nodeId) =>
nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
/// <summary>Build the internal map key for a realm-qualified string id. The key is the node's full
/// namespace-qualified NodeId string (e.g. <c>ns=2;s=&lt;id&gt;</c>) so a Raw node and a UNS node that
/// share the same <c>s=</c> identifier are distinct keys — a bare id is no longer globally unique across
/// the two namespaces. Matches <see cref="MapKey(NodeId)"/> for the same node.</summary>
/// <param name="realm">The realm the id lives in.</param>
/// <param name="id">The bare <c>s=</c> identifier.</param>
/// <returns>The namespace-qualified map key.</returns>
private string MapKey(AddressSpaceRealm realm, string id) => new NodeId(id, NamespaceIndexForRealm(realm)).ToString();
/// <summary>Build the internal map key for an already-resolved SDK <see cref="NodeId"/> (the SDK-facing
/// seams). Equal to <see cref="MapKey(AddressSpaceRealm, string)"/> for the same node.</summary>
/// <param name="nodeId">The resolved SDK node id.</param>
/// <returns>The namespace-qualified map key.</returns>
private static string MapKey(NodeId nodeId) => nodeId.ToString();
/// <summary>Gets the count of variable nodes currently managed.</summary>
public int VariableCount => _variables.Count;
/// <summary>Gets the count of folder nodes currently managed.</summary>
public int FolderCount => _folders.Count;
/// <summary>Gets the count of real Part 9 <see cref="AlarmConditionState"/> nodes currently managed.</summary>
public int AlarmConditionCount => _alarmConditions.Count;
/// <summary>
/// Reverse-path sink for inbound OPC UA Part 9 alarm method calls. When a client invokes a
/// materialised condition's Acknowledge / Confirm / Shelve / AddComment method, the condition's
/// handler (wired in <see cref="MaterialiseAlarmCondition"/>) gates on the caller's
/// <c>AlarmAck</c> role and, when allowed, builds an <see cref="AlarmCommand"/> and invokes this
/// delegate. The host sets it at boot to a non-blocking <c>mediator.Tell</c> onto the
/// <c>alarm-commands</c> DistributedPubSub topic; T19's engine-side subscriber consumes it.
/// <para>
/// This is the ONLY reverse coupling out of the node manager — by design it is a plain
/// <see cref="Action{AlarmCommand}"/> (no Akka / <c>IActorRef</c> / DI handle). The handler
/// delegates run under the manager's <c>Lock</c>; the invoked action MUST be non-blocking
/// (a fire-and-forget <c>Tell</c>) so there is no deadlock. Null (the default) makes every
/// handler a safe no-op — it still gates + returns, just routes nowhere.
/// </para>
/// </summary>
public Action<AlarmCommand>? AlarmCommandRouter { get; set; }
/// <summary>
/// H6c — reverse-path sink for an inbound OPC UA Acknowledge on a NATIVE (driver-fed, e.g. Galaxy)
/// Part 9 condition. The scripted-alarm engine does not own native conditions, so when a client
/// Acknowledges one, the condition's <c>OnAcknowledge</c> handler (wired in
/// <see cref="MaterialiseAlarmCondition"/>) branches on <see cref="IsNativeAlarmNode"/> and — after
/// the same <c>AlarmAck</c> role gate as <see cref="AlarmCommandRouter"/> — invokes THIS delegate
/// with a <see cref="NativeAlarmAck"/> instead of routing an <see cref="AlarmCommand"/> to the
/// scripted engine. The host sets it at boot to a non-blocking dispatch toward the backing driver
/// (a later task wires the driver linkage).
/// <para>
/// Like <see cref="AlarmCommandRouter"/>, the handler delegate runs under the manager's
/// <c>Lock</c>, so the invoked action MUST be non-blocking (fire-and-forget). Null (the default)
/// makes the native-ack handler a safe no-op — it still gates + returns, just routes nowhere.
/// Only the Acknowledge of a native condition uses this seam; Confirm/AddComment/Shelve on a
/// native condition stay on the scripted <see cref="AlarmCommandRouter"/> path (H6c scope is the
/// Acknowledge → driver path only).
/// </para>
/// </summary>
public Action<NativeAlarmAck>? NativeAlarmAckRouter { get; set; }
private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance;
/// <summary>
/// Reverse-path gateway for inbound OPC UA operator writes to a writable equipment-tag variable node.
/// When a client writes such a node, the node's <see cref="BaseDataVariableState.OnWriteValue"/>
/// handler (<see cref="OnEquipmentTagWrite"/>, attached by <see cref="EnsureVariable"/> when the
/// variable is writable) first gates on the caller's <see cref="OpcUaDataPlaneRoles.WriteOperate"/>
/// role and, when allowed, calls <see cref="IOpcUaNodeWriteGateway.WriteAsync"/> with the node's
/// string id + the written value to route the write to the backing driver.
/// <para>
/// This is the write-side twin of <see cref="AlarmCommandRouter"/>; the gateway abstraction keeps
/// this assembly Akka-free (the host wires an <c>ActorNodeWriteGateway</c> that Asks the local
/// <c>DriverHostActor</c>). The handler delegates run under the node-manager <c>Lock</c> (the OPC
/// UA SDK's <c>CustomNodeManager2.Write</c> holds <c>Lock</c> while invoking <c>OnWriteValue</c>),
/// so the dispatch is FIRE-AND-FORGET — the handler kicks off <c>WriteAsync</c> and returns
/// <c>Good</c> immediately so the SDK applies the client value optimistically; it MUST NOT block
/// on the device round-trip. When the asynchronous <see cref="NodeWriteOutcome"/> comes back
/// FAILED, an off-Lock continuation self-corrects: it re-takes <c>Lock</c> and reverts the node to
/// its real pre-write value — but only while the node still holds the optimistic value, so a fresh
/// driver poll that has already moved the node on is not clobbered (see
/// <see cref="ShouldRevert"/> / <see cref="RevertOptimisticWriteIfNeeded"/>).
/// </para>
/// <para>
/// Set by the host at <c>StartAsync</c>; the <see cref="NullOpcUaNodeWriteGateway"/> default
/// (assigning <c>null</c> restores it) makes every write resolve to a "writes unavailable"
/// failure. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make the
/// startup-write / SDK-thread-read explicit: the host assigns it once at boot on the start thread
/// and the SDK reads it on Write request threads.
/// </para>
/// </summary>
public IOpcUaNodeWriteGateway NodeWriteGateway
{
get => _nodeWriteGateway;
set => _nodeWriteGateway = value ?? NullOpcUaNodeWriteGateway.Instance;
}
private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;
/// <summary>
/// Server-side read backend for the OPC UA HistoryRead service over historized variable nodes.
/// When a client issues a HistoryRead (Raw / Processed / AtTime) against a node materialised
/// <c>Historizing</c> (a tag with <see cref="TryGetHistorizedTagname"/> registered), the
/// HistoryRead override resolves the node's NodeId to its historian tagname and dispatches to
/// this source — so a single registered historian (e.g. Wonderware) serves many drivers' nodes,
/// independent of any driver's lifecycle.
/// <para>
/// Set by the Host at <c>StartAsync</c>. The <see cref="NullHistorianDataSource"/>
/// default (assigning <c>null</c> restores it) means "no historian wired" → every read
/// returns empty, so a historized node's HistoryRead surfaces <c>GoodNoData</c> rather than
/// faulting. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make
/// the startup-write / SDK-read-thread handoff explicit: the Host assigns it once at boot on
/// the start thread and the SDK reads it on HistoryRead request threads. Unlike
/// <see cref="NodeWriteGateway"/>, the HistoryRead override does NOT run under the
/// node-manager <c>Lock</c>, so the override may block-bridge to this (async) source.
/// </para>
/// </summary>
public IHistorianDataSource HistorianDataSource
{
get => _historianDataSource;
set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
}
/// <summary>
/// The upper bound on the bounded over-fetch <see cref="ServeRawPaged"/> uses to page WITHIN an
/// oversized "tie cluster" — more raw samples sharing one SourceTimestamp than the client's per-page
/// cap. When a resume read stalls on such a cluster (the boundary-tie trim empties the page), the
/// paging over-fetches up to <c>MaxTieClusterOverfetch + 1</c> ties at that single timestamp (a
/// <c>start == end</c> read) and slices through them via <see cref="HistoryPaging.SliceTieCluster"/>.
/// A cluster strictly larger than this still surfaces <c>BadHistoryOperationUnsupported</c> for that
/// node (the absurd-burst backstop). Mirrors the configured
/// <c>ServerHistorianOptions.MaxTieClusterOverfetch</c>; the Host sets it at <c>StartAsync</c>. The
/// default (65536) survives when the historian section is absent.
/// </summary>
public int MaxTieClusterOverfetch { get; set; } = 65536;
/// <summary>
/// Maximum historized nodes served concurrently within one HistoryRead batch (arch-review 03/S3).
/// Mirrors <c>ServerHistorianOptions.HistoryReadBatchParallelism</c>; the Host sets it at
/// <c>StartAsync</c>. <c>≤ 1</c> falls back to sequential serving. The default (4) survives when the
/// ServerHistorian section is absent.
/// </summary>
public int HistoryReadBatchParallelism { get; set; } = 4;
/// <summary>
/// Process-wide cap on concurrently-served HistoryRead batches (arch-review 03/S3). A batch that
/// cannot acquire a slot within its (bounded) wait budget fast-fails every handle with
/// <c>BadTooManyOperations</c>. Mirrors <c>ServerHistorianOptions.MaxConcurrentHistoryReadBatches</c>;
/// the Host sets it at <c>StartAsync</c>. The default (16) survives when the section is absent.
/// </summary>
public int MaxConcurrentHistoryReadBatches { get; set; } = 16;
/// <summary>
/// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A node still in
/// flight at the deadline surfaces <c>BadTimeout</c>. Non-positive = unbounded. Mirrors
/// <c>ServerHistorianOptions.HistoryReadDeadline</c>; the Host sets it at <c>StartAsync</c>. The
/// default (60 s) survives when the section is absent.
/// </summary>
public TimeSpan HistoryReadDeadline { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Lazily-created process-wide HistoryRead-batch limiter (see <see cref="MaxConcurrentHistoryReadBatches"/>).</summary>
private volatile SemaphoreSlim? _historyReadBatchLimiter;
private readonly object _historyReadBatchLimiterLock = new();
private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore();
/// <summary>
/// The store that holds the server-side resume state behind an opaque HistoryRead continuation
/// point for the count-capped variable-history arms (Raw / Processed). The default
/// <see cref="SessionHistoryContinuationStore"/> binds points to the OPC UA session — so they are
/// capped (<c>ServerConfiguration.MaxHistoryContinuationPoints</c>, SDK default 100, oldest-evicted)
/// and disposed when the session closes. Exposed (internal) so the session-less in-process tests can
/// inject an <see cref="InMemoryHistoryContinuationStore"/> and exercise the full multi-page round
/// trip through the same dispatch path. Assigning <c>null</c> restores the session-backed default.
/// </summary>
internal IHistoryContinuationStore HistoryContinuationStore
{
get => _historyContinuationStore;
set => _historyContinuationStore = value ?? new SessionHistoryContinuationStore();
}
/// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
/// <returns>The cached <see cref="AlarmConditionState"/>, or null when none is registered.</returns>
public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var condition) ? condition : null;
/// <summary>Phase C: look up the resolved historian tagname registered for a historized variable
/// node, or null when the node is not historized. The (later) HistoryRead override resolves an
/// inbound HistoryRead request's NodeId against this map. Exposed for tests + the override.</summary>
/// <param name="nodeId">The variable node identifier.</param>
/// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
/// <returns>True when the node is registered as historized; otherwise false.</returns>
public bool TryGetHistorizedTagname(string nodeId, out string? tagname, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (_historizedTagnames.TryGetValue(MapKey(realm, nodeId), out var t)) { tagname = t; return true; }
tagname = null;
return false;
}
/// <summary>NodeId-typed overload used by the HistoryRead override, which holds a request's full
/// (namespace-qualified) NodeId — resolves the historian tagname without recovering the realm first.
/// A historized UNS reference node and its backing raw node register the SAME tagname under distinct
/// NodeIds, so a read against either resolves the one series.</summary>
/// <param name="nodeId">The resolved SDK node id from the HistoryRead request.</param>
/// <param name="tagname">The resolved historian tagname when historized; otherwise null.</param>
/// <returns>True when the node is registered as historized; otherwise false.</returns>
private bool TryGetHistorizedTagname(NodeId nodeId, out string? tagname)
{
if (_historizedTagnames.TryGetValue(MapKey(nodeId), out var t)) { tagname = t; return true; }
tagname = null;
return false;
}
/// <summary>Look up a materialised variable node by its NodeId string, or null if not present.
/// Exposed for tests so they can assert the SDK node's Historizing / AccessLevel attributes.</summary>
/// <param name="nodeId">The variable node identifier.</param>
/// <returns>The cached <see cref="BaseDataVariableState"/>, or null when none is registered.</returns>
internal BaseDataVariableState? TryGetVariable(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_variables.TryGetValue(MapKey(realm, nodeId), out var variable) ? variable : null;
/// <summary>Look up a materialised folder node by its NodeId string, or null if not present.
/// Exposed for tests so they can resolve an equipment folder's NodeId (e.g. the event-notifier
/// node a HistoryReadEvents request targets).</summary>
/// <param name="nodeId">The folder node identifier.</param>
/// <returns>The cached <see cref="FolderState"/>, or null when none is registered.</returns>
internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
_folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null;
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
/// materialised alarm condition <paramref name="alarmNodeId"/> — for a native alarm this is the number of
/// referencing equipment folders wired via <see cref="WireAlarmNotifiers"/>. Zero when the condition is
/// absent. Used by the multi-notifier + teardown-symmetry tests to prove no notifier entry leaks/duplicates
/// across redeploys.</summary>
/// <param name="alarmNodeId">The alarm condition node identifier.</param>
/// <param name="realm">The realm the condition lives in (Raw for native alarms).</param>
/// <returns>The number of notifier entries on the condition.</returns>
internal int AlarmNotifierCount(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
{
lock (Lock)
{
if (!_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var alarm)) return 0;
var list = new List<NodeState.Notifier>();
alarm.GetNotifiers(SystemContext, list);
return list.Count;
}
}
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
/// materialised folder <paramref name="folderNodeId"/> — for an equipment folder wired as an alarm notifier
/// this counts the inverse links back to its condition(s). Zero when the folder is absent.</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="realm">The realm the folder lives in (Uns for equipment folders).</param>
/// <returns>The number of notifier entries on the folder.</returns>
internal int FolderNotifierCount(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
{
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return 0;
var list = new List<NodeState.Notifier>();
folder.GetNotifiers(SystemContext, list);
return list.Count;
}
}
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): true when <paramref name="folderNodeId"/> is
/// registered as a root (Server-object) event notifier — i.e. it was promoted via
/// <see cref="EnsureFolderIsEventNotifier"/> and not yet torn down.</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="realm">The realm the folder lives in.</param>
/// <returns>True when the folder is a registered root notifier.</returns>
internal bool IsRootNotifier(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
return _folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)
&& _notifierFolders.ContainsKey(folder.NodeId);
}
/// <summary>
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
/// variable node on first call; subsequent calls update Value + StatusCode +
/// SourceTimestamp and call <c>ClearChangeMasks</c> so subscribed clients see the change.
/// </summary>
/// <param name="nodeId">The node identifier of the variable.</param>
/// <param name="value">The new value to write.</param>
/// <param name="quality">The OPC UA quality status code.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(nodeId);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, nodeId);
lock (Lock)
{
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
if (!_variables.TryGetValue(key, out var variable))
{
variable = CreateVariable(nodeId, ns);
_variables[key] = variable;
}
variable.Value = value;
variable.StatusCode = StatusFromQuality(quality);
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
/// <summary>
/// Apply a full Part 9 alarm-condition write. When a real <see cref="AlarmConditionState"/> has
/// been materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
/// this projects the whole <paramref name="state"/> snapshot
/// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
/// node and recomputes Retain (T15 — richer state; <b>still no event firing</b>, that lands in T16).
/// Otherwise it falls back to the legacy two-element <c>[Active, Acknowledged]</c>
/// <see cref="BaseDataVariableState"/> placeholder so callers whose alarm node hasn't been
/// materialised (and the existing unit tests) keep working.
/// </summary>
/// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
/// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(state);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, alarmNodeId);
// Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
// _alarmConditions / detach the condition node between the lookup and the Set* calls.
lock (Lock)
{
if (_alarmConditions.TryGetValue(key, out var condition))
{
// T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting
// the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming
// snapshot to that current state. We gate against the NODE's state, NOT a "last written"
// cache, because an inbound client ack the SDK applied (OnAcknowledge returned Good →
// SDK mutated AckedState + auto-fired its own event) NEVER passed through this method, so a
// last-written cache would be stale and wrongly report a delta. By the time the engine
// re-projects that ack here, the node already holds the acked state → no delta → suppress.
var current = ReadConditionDelta(condition);
var incoming = ToConditionDelta(state, condition);
bool fire = ShouldFireConditionEvent(current, incoming);
// EnabledState / AckedState / ActiveState are mandatory children — always present after
// Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is
// that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a
// future SDK that builds a leaner child set) may leave them null. Null-guard each optional
// child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a
// no-op rather than an NRE.
condition.SetEnableState(SystemContext, state.Enabled);
condition.SetActiveState(SystemContext, state.Active);
condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (condition.ConfirmedState is not null)
{
condition.SetConfirmedState(SystemContext, state.Confirmed);
}
if (condition.ShelvingState is not null)
{
// SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
// (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the
// SDK timer, so we pass shelvingTime=0 (no SDK-managed auto-unshelve).
condition.SetShelvingState(
SystemContext,
shelved: state.Shelving != AlarmShelvingKind.Unshelved,
oneShot: state.Shelving == AlarmShelvingKind.OneShot,
shelvingTime: 0);
}
condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
condition.Message.Value = new LocalizedText(state.Message);
// Part 9: retain the condition while it is active OR unacknowledged so a client's
// 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;
// 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
// genuine engine-driven transition (alarm goes active/clear, severity bucket shifts, an
// engine-side ack, etc.) differs from the node's current state → fire. The re-projection
// of a client ack the SDK already applied equals the node's current state → no delta →
// suppress, so we don't double-emit (E2 from the SDK + E3 from here). ReportConditionEvent
// stamps a fresh EventId, ClearChangeMasks, and ReportEvent — all still under this lock.
if (fire)
{
ReportConditionEvent(condition, sourceTimestampUtc);
}
return;
}
// Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so
// un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates
// the SDK address space, so it MUST run under Lock (see WriteValue).
if (!_variables.TryGetValue(key, out var variable))
{
variable = CreateVariable(alarmNodeId, ns);
_variables[key] = variable;
}
variable.Value = new[] { state.Active, state.Acknowledged };
variable.StatusCode = StatusCodes.Good;
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
/// <summary>
/// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
/// materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
/// have applied the new state via the <c>Set*</c> projection — this stamps a fresh per-event
/// <c>EventId</c>, <c>ClearChangeMasks</c>, then <c>ReportEvent</c> with an
/// <see cref="InstanceStateSnapshot"/> (a frozen copy of the condition's children at fire time,
/// so a subscribing client sees the values at this instant even if the live node mutates after).
/// <para>
/// A fresh <c>EventId</c> per event is a Part 9 requirement: inbound Acknowledge / Confirm /
/// AddComment calls are correlated back to a specific event by this id (the SDK matches it via
/// <c>GetEventByEventId</c> / <c>GetBranch</c>), so T17's ack routing relies on it being unique
/// per emission. We use the main branch only (<c>BranchId == NodeId.Null</c>, set at
/// materialise) — no branch creation here.
/// </para>
/// <para>
/// <b>Double-emit note (resolved by delta-gate).</b> An inbound client Acknowledge/Confirm
/// goes through the SDK's own handler, which (after T18's gate returns Good) applies the acked
/// state to the node and auto-fires its own condition event (E2) — directly on the node,
/// BYPASSING <see cref="WriteAlarmCondition"/>. The engine then re-projects that same logical
/// transition through <see cref="WriteAlarmCondition"/>, which would otherwise fire a second
/// event (E3). <see cref="WriteAlarmCondition"/>'s delta-gate suppresses E3: it compares the
/// incoming snapshot against the NODE's CURRENT state, and because the SDK has ALREADY
/// pre-applied the inbound-ack state, the re-projection is a no-delta no-op (no fire). Genuine
/// engine-driven transitions still differ from the node's current state, so they fire here as
/// before.
/// </para>
/// </summary>
/// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
/// <param name="ts">The source/receive timestamp (UTC) for this event.</param>
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();
// 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;
// Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so
// the InstanceStateSnapshot captures the just-projected values.
alarm.ClearChangeMasks(SystemContext, includeChildren: true);
try
{
// InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire
// time. ReportEvent walks inverse notifier references up to the root-notifier folder (promoted
// in MaterialiseAlarmCondition), whose OnReportEvent hands off to Server.ReportEvent → the
// event reaches subscribed monitored items.
var snapshot = new InstanceStateSnapshot();
snapshot.Initialize(SystemContext, alarm);
alarm.ReportEvent(SystemContext, snapshot);
}
catch (Exception ex)
{
// A failed event report must NOT break the state projection or the calling actor: the node's
// state has already been applied + ClearChangeMasks'd, so attribute subscribers still see the
// change; only the event delivery is lost. This CustomNodeManager2 carries no ILogger, so log
// through the SDK's static trace (Utils.LogError) instead of swallowing silently — a recurring
// failure here is then visible in the server log rather than invisible. T19's live Client.CLI
// run is the integration proof that the happy path delivers.
// Utils.LogError routes to the SDK's trace sink. It's [Obsolete] in 1.5.378 in favour of an
// ITelemetryContext/ILogger this CustomNodeManager2 doesn't have wired — suppress the
// deprecation here (wiring the telemetry logger through is a separate follow-up); the point is
// that a recurring failure is visible in the server trace rather than silently swallowed.
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {0}", alarm.NodeId);
#pragma warning restore CS0618
}
}
/// <summary>
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
/// condition event AND that an <see cref="AlarmConditionSnapshot"/> can change. As a record, two
/// instances compare by value, so <see cref="ShouldFireConditionEvent"/> is a plain inequality.
/// <para>
/// <b>Severity</b> is stored as the MAPPED <see cref="EventSeverity"/> bucket (a
/// <see cref="ushort"/>) — the same value the node holds after <c>SetSeverity</c> — so two
/// raw severities that fall in the same bucket are correctly treated as "no change". The
/// <b>Shelving</b> kind is read back from / mapped to the SDK's shelving state machine so the
/// live node and the snapshot compare on the same 3-way (Unshelved/OneShot/Timed) axis.
/// </para>
/// <para>
/// <b>Why <c>CommentAdded</c> is not a field here (intentional).</b>
/// <c>EmissionKind.CommentAdded</c> is produced only by
/// <c>Part9StateMachine.ApplyAddComment</c>, which is reached only via
/// <c>ScriptedAlarmEngine.AddCommentAsync</c>, which is called only from
/// <c>ScriptedAlarmHostActor</c>'s inbound <c>AlarmCommand</c> handler — meaning
/// <c>CommentAdded</c> ALWAYS originates from a client calling the condition's
/// <c>AddComment</c> method. On that path T18's <c>OnAddComment</c> delegate returns
/// <c>ServiceResult.Good</c>, so the OPC UA SDK itself applies the comment to the node
/// and auto-fires the Part 9 comment event (E2) directly to subscribers — BEFORE the
/// engine re-projects via <see cref="WriteAlarmCondition"/>. When that re-projection
/// arrives here, the delta-gate sees no change in any compared field (the snapshot carries
/// no comments list) and correctly suppresses a second event (E3). Force-firing for
/// <c>CommentAdded</c> would double-emit. There is no engine-internal or script-driven
/// comment path, so suppression never drops a needed event.
/// </para>
/// <para>
/// <b>Why <c>Retain</c> is absent (intentional — safe today).</b>
/// <c>Retain</c> is projected as <c>state.Active || !state.Acknowledged</c> in
/// <see cref="WriteAlarmCondition"/>. Every path that flips <c>Retain</c> necessarily
/// changes <c>Active</c> or <c>Acknowledged</c> (both ARE compared fields), so a
/// <c>Retain</c> flip always rides along with a real delta and fires correctly. If a
/// future engine were to set <c>Retain</c> independently — without touching
/// <c>Active</c>/<c>Acknowledged</c> — it would need to be added here.
/// </para>
/// </summary>
internal readonly record struct AlarmConditionDelta(
bool Active,
bool Acknowledged,
bool Confirmed,
bool Enabled,
AlarmShelvingKind Shelving,
ushort MappedSeverity,
string Message);
/// <summary>Decide whether a <see cref="WriteAlarmCondition"/> projection is a genuine state change
/// (and so should fire a Part 9 condition event) by comparing the node's pre-projection state to the
/// incoming snapshot. Pure + value-based so it's unit-testable in isolation: returns <c>true</c> iff
/// any gate-relevant field differs. An inbound client ack the SDK already applied makes
/// <paramref name="current"/> == <paramref name="incoming"/> ⇒ <c>false</c> (suppress the re-projected
/// double-emit); a genuine engine-driven transition differs ⇒ <c>true</c> (fire).</summary>
/// <param name="current">The node's current (pre-projection) gate-relevant state.</param>
/// <param name="incoming">The incoming snapshot's gate-relevant state.</param>
/// <returns><c>true</c> to fire a condition event; <c>false</c> to suppress (no delta).</returns>
internal static bool ShouldFireConditionEvent(AlarmConditionDelta current, AlarmConditionDelta incoming) =>
current != incoming;
/// <summary>Read the gate-relevant slice off the LIVE condition node. Mandatory children
/// (Active/Acked/Enabled) are always present; Confirmed/Shelving are optional and null-guarded
/// (a leaner child set ⇒ treat as the unset default). Severity is read as the already-mapped
/// <see cref="EventSeverity"/> bucket the node stores, and shelving is mapped from the shelving
/// state machine's CurrentState so it lines up with <see cref="ToConditionDelta"/>.</summary>
private static AlarmConditionDelta ReadConditionDelta(AlarmConditionState condition) => new(
Active: condition.ActiveState?.Id?.Value ?? false,
Acknowledged: condition.AckedState?.Id?.Value ?? true,
Confirmed: condition.ConfirmedState?.Id?.Value ?? true,
Enabled: condition.EnabledState?.Id?.Value ?? true,
Shelving: ReadShelvingKind(condition),
MappedSeverity: condition.Severity?.Value ?? (ushort)0,
Message: condition.Message?.Value?.Text ?? string.Empty);
/// <summary>Build the gate-relevant slice from the incoming snapshot, normalising the two fields that
/// the node stores in a derived form: Severity is run through <see cref="MapSeverity"/> so it matches
/// the bucket the node holds (the projection calls <c>SetSeverity(MapSeverity(...))</c>), and an
/// optional Confirmed/Shelving that the node can't actually hold (missing child) is folded to the
/// node's read-back default so it never spuriously registers as a delta.</summary>
private static AlarmConditionDelta ToConditionDelta(AlarmConditionSnapshot state, AlarmConditionState condition) => new(
Active: state.Active,
Acknowledged: state.Acknowledged,
// If the node has no ConfirmedState child, the projection is a no-op there; mirror the node's
// read-back default (true) so a snapshot Confirmed value can't create a phantom delta.
Confirmed: condition.ConfirmedState is not null ? state.Confirmed : true,
Enabled: state.Enabled,
// Likewise for shelving: without a ShelvingState child the projection can't apply, so fold to the
// node's read-back default (Unshelved).
Shelving: condition.ShelvingState is not null ? state.Shelving : AlarmShelvingKind.Unshelved,
MappedSeverity: (ushort)MapSeverity(state.Severity),
Message: state.Message ?? string.Empty);
/// <summary>Map the live shelving state machine's CurrentState back to our 3-way
/// <see cref="AlarmShelvingKind"/> by matching its well-known Part 9 state object id. Any node without
/// a shelving sub-state machine (or an unrecognised/unset state) reads as
/// <see cref="AlarmShelvingKind.Unshelved"/> — the same value <see cref="ToConditionDelta"/> folds an
/// unsupported shelving snapshot to, so the two stay comparable.</summary>
private static AlarmShelvingKind ReadShelvingKind(AlarmConditionState condition)
{
var stateId = condition.ShelvingState?.CurrentState?.Id?.Value as NodeId;
if (stateId == ObjectIds.ShelvedStateMachineType_OneShotShelved) return AlarmShelvingKind.OneShot;
if (stateId == ObjectIds.ShelvedStateMachineType_TimedShelved) return AlarmShelvingKind.Timed;
return AlarmShelvingKind.Unshelved;
}
/// <summary>
/// Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> node under its equipment
/// folder so clients can browse it as a proper condition (and subscribe to its events). The node
/// id is the alarm node id (the ScriptedAlarmId) so subsequent
/// <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
/// <para>
/// This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
/// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
/// <b>no</b> events itself.
/// </para>
/// Idempotent: a second call with the same <paramref name="alarmNodeId"/> tears down the prior
/// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
/// </summary>
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.</param>
/// <param name="equipmentNodeId">The equipment folder node id the condition parents under (null/unknown ⇒ root).</param>
/// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
/// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
/// <param name="severity">Domain severity (treated as an OPC UA 1..1000 severity); mapped to <see cref="EventSeverity"/>.</param>
/// <param name="isNative">True when the condition is a NATIVE (driver-fed, e.g. Galaxy) alarm rather than a scripted one.</param>
/// <remarks>
/// <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
/// setpoint values, so any limit-style subtype would have unset limit children. We therefore
/// map: <c>OffNormalAlarm</c> → <see cref="OffNormalAlarmState"/>, <c>DiscreteAlarm</c> →
/// <see cref="DiscreteAlarmState"/>, and everything else (including <c>AlarmCondition</c> and
/// <c>LimitAlarm</c>, which has no script-supplied limits) → the base
/// <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
/// notes — a script alarm carries no High/Low limits to populate.</para>
/// </remarks>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var conditionKey = MapKey(realm, alarmNodeId);
lock (Lock)
{
// R2-07 T4a: idempotent skip-if-present-and-same-kind. On a PURE-ADD apply,
// MaterialiseScriptedAlarms + the native-alarm materialise re-run over the FULL composition, so
// an existing enabled condition is re-materialised with the SAME id + kind. Skip it — KEEP the
// existing AlarmConditionState instance alive so every MonitoredItem on that condition node
// survives the deploy (the drop-and-recreate below would otherwise kill them). A genuine
// re-severity/type change arrives as a ChangedAlarms delta ⇒ full rebuild (the map is cleared
// first), so it never reaches this path with a stale-but-present entry; the drop-and-recreate
// stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag.
if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative)
return;
// Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
// type/severity on redeploy) reflects cleanly instead of leaking the old node.
if (_alarmConditions.TryRemove(conditionKey, out var existing))
{
// Wave C review L1: unwire the OLD instance's notifier pairs BEFORE discarding it, so no
// referencing equipment folder keeps a dangling inverse-notifier entry to the dropped
// AlarmConditionState. Unreachable today (native=Raw/RawPath, scripted=Uns/ScriptedAlarmId — no
// same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown
// path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace.
UnwireAlarmNotifiers(conditionKey);
existing.Parent?.RemoveChild(existing);
PredefinedNodes?.Remove(existing.NodeId);
}
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
// kind, so always drop the stale native flag first and only re-add it below when isNative.
_nativeAlarmNodeIds.Remove(conditionKey);
var parent = ResolveParentFolder(equipmentNodeId, realm);
AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
alarm.SymbolicName = displayName;
// HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;
// Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
// ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
// type's embedded definition; we do not hand-build them.
alarm.Create(
SystemContext,
new NodeId(alarmNodeId, ns),
new QualifiedName(displayName, ns),
new LocalizedText(displayName),
assignNodeIds: true);
// Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
// UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
// Create leaves as a null reference and would NRE. NodeId.Null marks "the main branch".
// (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;
// Initial state via the SDK setters (T14: basic state only, NO event firing).
alarm.SetEnableState(SystemContext, true);
alarm.SetActiveState(SystemContext, false);
alarm.SetAcknowledgedState(SystemContext, true);
alarm.SetSeverity(SystemContext, MapSeverity(severity));
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/
// AddComment/Shelve/Unshelve method nodes and the condition types wired their built-in OnCall
// routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
// state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
// routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
// SDK applies its node state + auto-fires its own event (E2).
// T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
// delta-gate (compares against the node's current state, which the SDK already pre-applied)
// sees no change and suppresses the would-be second event (E3) — so no double-emit.
// H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
// scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/
// Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only).
alarm.OnAcknowledge = (context, condition, _, comment) =>
IsNativeAlarmNode(alarmNodeId, realm)
? HandleNativeAlarmAck(context, condition, comment)
: HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
alarm.OnConfirm = (context, condition, _, comment) =>
HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
alarm.OnAddComment = (context, condition, _, comment) =>
HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
{
// SDK invocation shapes (verified against the decompiled AlarmConditionState):
// OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
// TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
// Unshelve → (shelving:false, oneShot:false, 0.0) ⇒ Unshelve, no expiry
// shelvingTime is an OPC UA Duration (milliseconds).
var (operation, unshelveAt) =
!shelving ? ("Unshelve", (DateTime?)null)
: oneShot ? ("OneShotShelve", null)
: ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime));
return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt);
};
// The auto-unshelve timer callback is SDK-initiated (the TimedShelve duration expired); the SDK
// fires it with the node manager's system context — there is NO session and NO user identity.
// Routing through HandleAlarmCommand would hit the AlarmAck gate and return BadUserAccessDenied,
// leaving the alarm permanently shelved. Instead, bypass the client gate, extract the AlarmId the
// same way HandleAlarmCommand does, and route an Unshelve command so the engine clears its shelve
// state. The manual-client Unshelve path goes through OnShelve(shelving:false) and stays gated.
alarm.OnTimedUnshelve = (context, condition) =>
{
var alarmId = condition.NodeId.Identifier?.ToString() ?? string.Empty;
// User MUST be non-empty: the engine's Part9StateMachine.ApplyUnshelve rejects a
// null/whitespace user (ArgumentException "User required."), and that exception is swallowed
// downstream — an empty user would make the timed auto-unshelve silently no-op, so a
// TimedShelve would never auto-expire. There is no client principal on a system-timer
// unshelve, so label it the canonical engine-internal "system" user (matching the engine's
// own AutoUnshelve audit user and the codebase-wide "system" system-actor convention). The
// AlarmAck gate bypass is intentional and preserved — this is a session-less SDK timer.
AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", "system", null, null));
return ServiceResult.Good;
};
// H4 — inbound Part 9 Enable/Disable (the condition type's built-in Enable/Disable methods route
// here via this delegate). The engine handles Enable/Disable for SCRIPTED alarms
// (ScriptedAlarmEngine.EnableAsync/DisableAsync, dispatched by ScriptedAlarmHostActor on the
// "Enable"/"Disable" AlarmCommand operations), so a scripted condition routes through the same
// AlarmAck-gated HandleAlarmCommand as the other handlers. NATIVE (driver-fed) conditions have no
// engine enable/disable surface — they short-circuit to BadNotSupported.
alarm.OnEnableDisable = (context, condition, enabling) =>
{
if (IsNativeAlarmNode(alarmNodeId, realm))
return new ServiceResult(StatusCodes.BadNotSupported);
return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
};
parent.AddChild(alarm);
// Promote the equipment folder to an event notifier + register it as a root notifier so
// T16's ReportEvent has a notifier path up to the Server object. Guard so repeated
// materialise under the same folder doesn't double-add the root notifier.
EnsureFolderIsEventNotifier(parent);
AddPredefinedNode(SystemContext, alarm);
_alarmConditions[conditionKey] = alarm;
// H6a: record native (driver-fed) conditions so a later task can route their inbound
// Acknowledge to the driver rather than the scripted engine.
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
}
}
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
/// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
/// the instance changes).</summary>
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List<FolderState> Folders);
/// <inheritdoc cref="IOpcUaAddressSpaceSink.WireAlarmNotifiers"/>
/// <remarks>
/// Wave C review M3 — this is a RECONCILE, not additive-only: it wires the supplied set AND unwires any
/// previously-tracked folder whose id is no longer in <paramref name="notifierFolderNodeIds"/>
/// (bidirectional), so a de-referenced equipment stops receiving the alarm. Today the applier always
/// passes the COMPLETE referencing-equipment set for the condition, and a de-reference arrives as a
/// <c>ChangedRawTags</c> full rebuild (which clears the wiring), so the unwire leg is a no-op on the
/// current paths — but it makes this method correct for a FUTURE surgical <c>ChangedRawTags</c> path
/// (see <c>AddressSpaceChangeClassifier</c>'s <c>ChangedRawTags → Rebuild</c> branch, which carries the
/// matching binding guard).
/// </remarks>
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
ArgumentNullException.ThrowIfNull(notifierFolderNodeIds);
if (notifierFolderNodeIds.Count == 0) return;
EnsureAddressSpaceCreated();
var alarmKey = MapKey(alarmRealm, alarmNodeId);
lock (Lock)
{
// The condition must already be materialised (MaterialiseAlarmCondition ran first in the same
// apply pass). A miss ⇒ a mid-rebuild race cleared it: no-op (logged), never throw — a deploy must
// not fault on the notifier wiring.
if (!_alarmConditions.TryGetValue(alarmKey, out var alarm))
{
// This CustomNodeManager2 carries no ILogger; log through the SDK's static trace (see the
// ReportEvent catch below for the same pattern). Utils.LogInfo is [Obsolete] in 1.5.378 in
// favour of an ITelemetryContext this manager doesn't wire — suppress the deprecation.
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: condition {0} not materialised — skipping notifier wiring", alarmKey);
#pragma warning restore CS0618
return;
}
// Get-or-create the wiring entry; a rebuild recreated the AlarmConditionState instance, so reset
// the entry (the old folder list referenced the discarded instance) when the instance differs.
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm))
{
wiring = new AlarmNotifierWiring(alarm, new List<FolderState>());
_alarmNotifierWiring[alarmKey] = wiring;
}
// The DESIRED notifier-folder keys (ns-qualified) for this condition — the full set the caller
// supplied, regardless of whether each is currently materialised. Reconcile against it below.
var desiredKeys = notifierFolderNodeIds
.Select(id => MapKey(notifierFolderRealm, id))
.ToHashSet(StringComparer.Ordinal);
// Reconcile (M3): unwire any previously-wired folder whose id is NO LONGER in the supplied set — a
// genuine de-reference (the equipment dropped its reference to the alarm's raw tag). Matched by the
// folder's ns-qualified NodeId string so a transiently-unmaterialised-but-still-referenced folder
// (id still present) is NOT unwired. Bidirectional so the folder's inverse entry is removed too.
foreach (var wired in wiring.Folders.Where(w => !desiredKeys.Contains(MapKey(w.NodeId))).ToList())
{
wiring.Alarm.RemoveNotifier(SystemContext, wired, bidirectional: true);
wiring.Folders.Remove(wired);
}
foreach (var folderNodeId in notifierFolderNodeIds)
{
if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder))
{
// Referencing-equipment folder not (yet) materialised — skip this one (logged, never thrown).
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: notifier folder {0} for condition {1} not present — skipped",
folderNodeId, alarmKey);
#pragma warning restore CS0618
continue;
}
// Normative SDK pattern (design §"OPC UA address space + runtime binding"): a single
// ReportEvent on the condition bubbles through its HasComponent parent chain PLUS every inverse
// entry in its notifier list, so one event fans to every wired root WITHOUT re-reporting per
// root (distinct EventIds would break Server-object dedup + Part 9 ack correlation).
// alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder
// folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link
// EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent)
// AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply)
// updates rather than duplicates.
alarm.AddNotifier(SystemContext, null, isInverse: true, folder);
folder.AddNotifier(SystemContext, null, isInverse: false, alarm);
EnsureFolderIsEventNotifier(folder);
if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder);
}
}
}
/// <summary>Tear down (bidirectionally) every notifier wired for the condition at <paramref name="alarmKey"/>
/// and drop its tracking entry. MUST be called under <c>Lock</c>. Used on condition-removal + full rebuild
/// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.</summary>
private void UnwireAlarmNotifiers(string alarmKey)
{
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return;
foreach (var folder in wiring.Folders)
{
// bidirectional:true also removes the inverse entry the folder holds back to the condition.
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
}
_alarmNotifierWiring.Remove(alarmKey);
}
/// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> is a NATIVE
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
/// inbound Acknowledge to the driver instead of the scripted engine.</summary>
/// <param name="alarmNodeId">The alarm condition node id.</param>
/// <returns>True when the condition is native (driver-fed); false when it is scripted or not found.</returns>
internal bool IsNativeAlarmNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
// _nativeAlarmNodeIds is a plain HashSet mutated only under Lock (in MaterialiseAlarmCondition /
// RebuildAddressSpace), so guard the read with the same Lock rather than risk a torn concurrent read.
lock (Lock) return _nativeAlarmNodeIds.Contains(MapKey(realm, alarmNodeId));
}
/// <summary>
/// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling
/// principal off the SDK <paramref name="context"/>, applies the <c>AlarmAck</c> role gate
/// (<b>fails closed</b>: a missing identity or a missing role is denied), and on success builds a
/// mapped <see cref="AlarmCommand"/> and routes it through <see cref="AlarmCommandRouter"/>.
/// </summary>
/// <param name="context">The SDK context the handler delegate was invoked with — a
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session
/// identity. T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>.</param>
/// <param name="condition">The condition the method targets; its <c>NodeId</c> identifier is the
/// ScriptedAlarmId (T14 aligned them), which becomes <see cref="AlarmCommand.AlarmId"/>.</param>
/// <param name="operation">The Part 9 operation name (e.g. <c>Acknowledge</c>, <c>TimedShelve</c>).</param>
/// <param name="comment">The call's comment text, or <c>null</c> when none was supplied.</param>
/// <param name="unshelveAt">For <c>TimedShelve</c>, the computed UTC expiry; otherwise <c>null</c>.</param>
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its
/// event); <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
private ServiceResult HandleAlarmCommand(
ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
{
// Resolve the principal the SAME way the SDK's own GetCurrentUserId does, then narrow to the
// role-carrying identity T17 attached. Anonymous / non-role-carrying identities ⇒ null ⇒ denied.
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
{
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
// state change and surfaces the status to the client; we never route or mutate.
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
var cmd = new AlarmCommand(
AlarmId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
Operation: operation,
User: identity.DisplayName ?? string.Empty,
Comment: comment?.Text,
UnshelveAtUtc: unshelveAt);
// Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock.
AlarmCommandRouter?.Invoke(cmd);
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
return ServiceResult.Good;
}
/// <summary>
/// H6c — handler body for an inbound OPC UA Acknowledge on a NATIVE (driver-fed) condition. The
/// scripted engine does not own native conditions, so this routes a <see cref="NativeAlarmAck"/> to
/// <see cref="NativeAlarmAckRouter"/> (the driver-bound seam) rather than a scripted
/// <see cref="AlarmCommand"/> to <see cref="AlarmCommandRouter"/>. The calling principal is resolved
/// and the <c>AlarmAck</c> role gate applied EXACTLY as in <see cref="HandleAlarmCommand"/>
/// (<b>fails closed</b>: a missing identity or missing role is denied — no route, no state mutation).
/// </summary>
/// <param name="context">The SDK context the handler delegate was invoked with — a
/// <c>ServerSystemContext</c> (an <see cref="ISessionOperationContext"/>) carrying the session identity
/// (T17 attached the LDAP roles as a <see cref="RoleCarryingUserIdentity"/>).</param>
/// <param name="condition">The condition the Acknowledge targets; its <c>NodeId</c> identifier is the
/// folder-scoped condition node id the driver-bound router resolves back to a driver ref.</param>
/// <param name="comment">The acknowledge comment text, or <c>null</c> when none was supplied.</param>
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its event);
/// <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
{
// Resolve + gate the SAME way HandleAlarmCommand does so native and scripted acks share one authz
// contract. Anonymous / non-role-carrying identities ⇒ null ⇒ denied (fail closed, never route).
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.AlarmAck, StringComparer.OrdinalIgnoreCase))
{
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
// 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).
// Non-blocking by contract (host wires a fire-and-forget dispatch); safe to call under Lock.
NativeAlarmAckRouter?.Invoke(new NativeAlarmAck(
ConditionNodeId: condition.NodeId.Identifier?.ToString() ?? string.Empty,
Comment: comment?.Text,
OperatorUser: identity.DisplayName ?? string.Empty));
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
return ServiceResult.Good;
}
/// <summary>
/// The <see cref="NodeValueEventHandler"/> attached to a writable equipment-tag variable by
/// <see cref="EnsureVariable"/>. The OPC UA SDK invokes it when a client writes the
/// node's Value. It resolves the calling principal off the SDK <paramref name="context"/> the
/// SAME way <see cref="HandleAlarmCommand"/> does, gates on the
/// <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role + the gateway being wired
/// (<b>fails closed</b>: a missing identity / missing role ⇒ <c>BadUserAccessDenied</c>; no gateway ⇒
/// <c>BadNotWritable</c>) via the pure <see cref="EvaluateEquipmentWriteGate"/>, and on pass dispatches
/// the value through <see cref="NodeWriteGateway"/>.
/// <para>
/// The dispatch is FIRE-AND-FORGET: the SDK's <c>CustomNodeManager2.Write</c> holds the node
/// manager <c>Lock</c> while invoking this handler, so a blocking driver round-trip here would
/// freeze every address-space operation (reads, subscription notifications, the publish path) for
/// the duration. The gateway only kicks off the asynchronous route. Returning
/// <see cref="ServiceResult.Good"/> lets the SDK apply the written value optimistically.
/// </para>
/// <para>
/// <b>Item A — synchronous structural fail-fast.</b> After the authz gate passes but BEFORE the
/// optimistic dispatch, a pure <see cref="EvaluateEquipmentWriteStructure"/> pre-check rejects a
/// structurally-invalid write (e.g. a <c>null</c> payload, or a confidently-detected built-in-type
/// mismatch) INLINE — returning Bad synchronously so the SDK never applies it, avoiding the
/// optimistic-Good-then-revert round-trip + a pointless device dispatch.
/// </para>
/// <para>
/// <b>Write-outcome self-correction.</b> Before returning Good (which makes the SDK overwrite the
/// node with <paramref name="value"/>) we capture both the optimistic value AND the node's REAL
/// prior value/status — at handler entry the node still holds the prior value — plus the writing
/// principal's user-id (threaded to the audit event). An off-Lock continuation on the
/// <see cref="NodeWriteOutcome"/> then, on a FAILED outcome and ONLY while the node still holds the
/// optimistic value (so a fresh driver poll that already republished the confirmed register value is
/// not clobbered): surfaces a transient <b>Bad-quality blip</b> (Item B), reverts the node to its
/// prior value/status, and raises a Part 8 <b>AuditWriteUpdateEvent</b> (Item C) recording the
/// rejected write (<see cref="RevertOptimisticWriteIfNeeded"/> / <see cref="ShouldRevert"/>). On
/// success the optimistic value stands and the next poll re-confirms it via the normal
/// <see cref="WriteValue"/> path.
/// </para>
/// </summary>
private ServiceResult OnEquipmentTagWrite(
ISystemContext context, NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
ref object value, ref StatusCode statusCode, ref DateTime timestamp)
{
var identity = (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity;
var gateway = _nodeWriteGateway;
var gate = EvaluateEquipmentWriteGate(identity, gateway is not NullOpcUaNodeWriteGateway);
if (gate is not null) return gate;
// Item A (synchronous structural fail-fast): reject a structurally-invalid write INLINE — return Bad
// synchronously so the SDK never applies it (no optimistic-Good-then-revert round-trip + no needless
// device dispatch). Runs AFTER the authz gate (so we never leak structure detail to an unauthorised
// caller) but BEFORE the optimistic dispatch below.
var structure = EvaluateEquipmentWriteStructure(value, node);
if (structure is not null) return structure;
// 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;
// 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 — WP3's 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;
object? priorValue = null;
StatusCode priorStatus = StatusCodes.Good;
if (node is BaseDataVariableState variable)
{
priorValue = variable.Value;
priorStatus = variable.StatusCode;
}
// Item C: thread the writing principal's user-id string into the failure continuation so the audit
// event can populate ClientUserId. Resolved here off the same identity the gate used (null when the
// session is anonymous / carries no role-carrying identity — the gate would already have vetoed, so in
// practice non-null on this path, but kept defensive).
var clientUserId = identity?.DisplayName;
// Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock
// continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never
// sticks when the route never resolved a real outcome. RunContinuationsAsynchronously guarantees the
// revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed
// task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never
// re-enters lock (Lock) while CustomNodeManager2.Write still holds it.
_ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None)
.ContinueWith(
t =>
{
var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted");
RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm);
},
CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default);
return ServiceResult.Good;
}
/// <summary>
/// Pure role + availability gate for an inbound equipment-tag write, extracted off
/// <see cref="OnEquipmentTagWrite"/> so it is unit-testable without booting an SDK server. Fails closed:
/// a null identity or an identity missing the <see cref="OpcUaDataPlaneRoles.WriteOperate"/> role ⇒
/// <c>BadUserAccessDenied</c>. When the gate passes but no real gateway is wired
/// (<paramref name="gatewayWired"/> is false) ⇒ <c>BadNotWritable</c> ("writes unavailable"). A
/// <c>null</c> return means "proceed" (the caller dispatches + returns Good). Role comparison is
/// case-insensitive (the role set is built with <see cref="StringComparer.OrdinalIgnoreCase"/>),
/// matching the alarm gate.
/// </summary>
/// <param name="identity">The role-carrying identity extracted off the SDK context, or null when the
/// session is anonymous / carries no role-carrying identity.</param>
/// <param name="gatewayWired">True when a non-Null <see cref="IOpcUaNodeWriteGateway"/> is wired; false
/// for the Null default (no route — e.g. admin-only nodes / pre-boot).</param>
/// <returns><c>null</c> to proceed (gate passed); otherwise the veto <see cref="ServiceResult"/>
/// (<c>BadUserAccessDenied</c> on a failed role gate, <c>BadNotWritable</c> when no gateway is wired).</returns>
internal static ServiceResult? EvaluateEquipmentWriteGate(RoleCarryingUserIdentity? identity, bool gatewayWired)
{
if (identity is null || !identity.Roles.Contains(OpcUaDataPlaneRoles.WriteOperate, StringComparer.OrdinalIgnoreCase))
{
// Fail closed: no role / no identity ⇒ veto. Returning a bad ServiceResult aborts the SDK's
// write and surfaces the status to the client; we never route.
return new ServiceResult(StatusCodes.BadUserAccessDenied);
}
if (!gatewayWired)
{
// Gate passed but no gateway wired (admin-only nodes / pre-boot) ⇒ writes unavailable.
return new ServiceResult(StatusCodes.BadNotWritable, "writes unavailable");
}
return null;
}
/// <summary>
/// <b>Item A — synchronous structural fail-fast.</b> Pure structural pre-check for an inbound
/// equipment-tag write, run AFTER <see cref="EvaluateEquipmentWriteGate"/> passes but BEFORE the
/// optimistic device dispatch in <see cref="OnEquipmentTagWrite"/>. A structurally-invalid write is
/// rejected INLINE (a Bad <see cref="ServiceResult"/> is returned synchronously, so the SDK never
/// applies the value) instead of being optimistically applied and later reverted — saving both the
/// phantom value-blip and a pointless device round-trip.
/// <para>
/// <b>Interpretation / tradeoff (the item was deliberately under-specified).</b> The minimum
/// sensible structural check is a <c>null</c> value write to a value variable ⇒
/// <c>BadTypeMismatch</c> (a value node always holds a typed scalar/array; a null payload is never a
/// valid value write here). On top of that, when the node is a <see cref="BaseDataVariableState"/>
/// whose <see cref="BaseVariableState.DataType"/> resolves to a concrete built-in type AND the
/// written value's runtime built-in type is also resolvable, a CHEAP built-in-type compatibility
/// check is applied: a clear mismatch ⇒ <c>BadTypeMismatch</c>. The check is intentionally
/// conservative — it only rejects when BOTH the expected and actual built-in types are confidently
/// resolved AND differ (with numeric widening + the BaseDataType "accept anything" wildcard
/// allowed); anything uncertain (a non-variable node, an unresolved/abstract DataType, a
/// <see cref="Variant"/>/array payload whose element type isn't cheaply known) is allowed through so
/// the SDK's own (authoritative) type coercion in <c>BaseVariableState.WriteValue</c> remains the
/// final arbiter. We deliberately do NOT attempt deep array-dimension / structured-type validation
/// here — that is left to the SDK.
/// </para>
/// Pure (no SDK server / Lock needed): reads only <paramref name="value"/> and the node's static
/// <c>DataType</c>, so it is unit-testable in isolation.
/// </summary>
/// <param name="value">The value the client wrote (the SDK's pre-coercion payload).</param>
/// <param name="node">The target node (expected to be a writable <see cref="BaseDataVariableState"/>).</param>
/// <returns><c>null</c> to proceed; otherwise the veto <see cref="ServiceResult"/>
/// (<c>BadTypeMismatch</c> for a null write or a confidently-detected built-in-type mismatch).</returns>
internal static ServiceResult? EvaluateEquipmentWriteStructure(object? value, NodeState node)
{
// 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");
}
// 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.
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
// SAFETY BOUNDARY: only reject against the CLOSED set of built-in types a writable equipment node can
// actually carry (per ResolveBuiltInDataType). Any other expected type (Enumeration, Guid, NodeId,
// StatusCode, …) is DEFERRED to the SDK's authoritative coercion so this fail-fast can NEVER false-reject
// a write the SDK would have accepted (e.g. an Int32 payload to an Enumeration node coerces fine).
if (!IsCheckableExpectedType(expected)) return null;
// TypeInfo.Construct(object) classifies the runtime payload; an unclassifiable value ⇒ Unknown (Null).
var actual = TypeInfo.Construct(value).BuiltInType;
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 null;
}
/// <summary>
/// Conservative built-in-type compatibility test for <see cref="EvaluateEquipmentWriteStructure"/>.
/// Returns true (compatible — allow through) unless there is a confident mismatch. Exact matches pass;
/// numeric-to-numeric is treated as compatible (the SDK widens/narrows numerics); a
/// <see cref="BuiltInType.ByteString"/>↔<see cref="BuiltInType.Byte"/> array nuance and any
/// <see cref="BuiltInType.Variant"/> on either side are treated as compatible. Only a clear cross-family
/// mismatch (e.g. writing a String to a Boolean node) returns false. Pure + static.
/// </summary>
private static bool IsBuiltInTypeCompatible(BuiltInType expected, BuiltInType actual)
{
if (expected == actual) return true;
// Any side a wildcard/unclassified ⇒ defer (compatible) — the caller already filtered most of these.
if (expected is BuiltInType.Variant or BuiltInType.Null) return true;
if (actual is BuiltInType.Variant or BuiltInType.Null) return true;
// Numeric family widening/narrowing is the SDK's job; treat numeric↔numeric as compatible.
if (IsNumeric(expected) && IsNumeric(actual)) return true;
// ByteString and Byte are routinely interchangeable on the wire; don't reject that pairing.
if ((expected, actual) is (BuiltInType.ByteString, BuiltInType.Byte) or (BuiltInType.Byte, BuiltInType.ByteString)) return true;
return false;
}
/// <summary>True for the OPC UA numeric built-in types (the integer + floating families).</summary>
private static bool IsNumeric(BuiltInType t) => t is
BuiltInType.SByte or BuiltInType.Byte or BuiltInType.Int16 or BuiltInType.UInt16 or
BuiltInType.Int32 or BuiltInType.UInt32 or BuiltInType.Int64 or BuiltInType.UInt64 or
BuiltInType.Float or BuiltInType.Double;
/// <summary>The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject
/// against — exactly the types <see cref="ResolveBuiltInDataType"/> can emit for a writable equipment node
/// (the numeric families + Boolean / String / DateTime / ByteString). Any expected type OUTSIDE this set is
/// deferred to the SDK so a coercible write (Int32→Enumeration, etc.) is never false-rejected.</summary>
private static bool IsCheckableExpectedType(BuiltInType t) =>
IsNumeric(t) || t is BuiltInType.Boolean or BuiltInType.String or BuiltInType.DateTime or BuiltInType.ByteString;
/// <summary>
/// Pure decision for the write-outcome self-correction: revert the node to its pre-write value ONLY on
/// a FAILED outcome AND only while the node still holds the optimistic value. The
/// still-holds-the-optimistic-value check is what stops a revert from clobbering a fresh driver poll
/// that already republished the confirmed register value over the optimistic write. Pure (value
/// comparison via <see cref="object.Equals(object?, object?)"/>) so it is unit-testable without an SDK
/// server.
/// </summary>
/// <param name="outcome">The device-write outcome routed back by the gateway.</param>
/// <param name="currentNodeValue">The node's current Value at revert time.</param>
/// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
/// <returns><c>true</c> to revert (failed outcome and node unchanged since the optimistic write);
/// <c>false</c> on success, or when a poll has already moved the node off the optimistic value.</returns>
internal static bool ShouldRevert(NodeWriteOutcome outcome, object? currentNodeValue, object? optimisticValue) =>
!outcome.Success && Equals(currentNodeValue, optimisticValue);
/// <summary>
/// Off-Lock continuation body for the write-outcome self-correction: re-takes <c>Lock</c> and, when
/// <see cref="ShouldRevert"/> says so, surfaces the device-write rejection to subscribed clients in three
/// ways, then leaves the node holding its captured pre-write value/status (same node-update shape as
/// <see cref="WriteValue"/>). A no-op when the node was rebuilt/removed in the interim, when the outcome
/// succeeded, or when a fresh poll already moved the node off the optimistic value.
/// <list type="number">
/// <item>
/// <b>Item B — Bad-quality blip.</b> Before restoring the prior value, the node is published
/// once holding the (still-applied) optimistic value but with StatusCode
/// <c>BadDeviceFailure</c>, then published again with the prior value/status restored. A
/// value-subscribed client therefore sees the rejection (a Bad quality) rather than the value
/// silently snapping back. <b>Caveat:</b> the two <c>ClearChangeMasks</c> calls happen
/// back-to-back within one server publishing interval, so the SDK's monitored-item queue may
/// COALESCE them — a slow / queue-size-1 subscriber can see only the final restored value and
/// miss the transient Bad blip. The blip is a best-effort live signal; the
/// <see cref="AuditWriteUpdateEventState"/> raised below (Item C) is the reliable, durable record
/// of the rejected write.
/// </item>
/// <item>
/// <b>Item C — AuditWriteUpdateEvent.</b> A Part 8 <see cref="AuditWriteUpdateEventState"/> is
/// raised through the SDK <see cref="CustomNodeManager2.Server"/> so an auditing client gets a
/// durable record of the rejected write (OldValue = prior, NewValue = the attempted optimistic
/// value, the boolean AuditEvent Status = false ⇒ failed, and the device's
/// <paramref name="outcome"/>.Reason carried in the event Message). It is reported
/// OUTSIDE <c>Lock</c> (the event-state is built under Lock, then reported after release) to keep
/// the lock hold short and avoid any re-entrancy risk via the server's event path. Auditing being
/// disabled / no subscribers is handled gracefully — <c>ReportEvent</c> simply reaches no monitored
/// items, and any failure is swallowed (logged to the SDK trace) so the revert is never broken by it.
/// </item>
/// </list>
/// Silent value-wise — this node manager carries no logger; the gateway logs the underlying write failure
/// and the SDK trace captures any audit-report failure.
/// <para><c>internal</c> (not private) so the behavioural test drives the blip-then-settle continuation
/// against a booted node manager — see <c>NodeManagerWriteRevertTests</c>.</para>
/// </summary>
/// <param name="nodeId">The string id of the written variable node.</param>
/// <param name="outcome">The device-write outcome routed back by the gateway.</param>
/// <param name="optimisticValue">The value the SDK optimistically applied on the write.</param>
/// <param name="priorValue">The node's real value captured before the optimistic write.</param>
/// <param name="priorStatus">The node's real status captured before the optimistic write.</param>
/// <param name="clientUserId">The writing principal's user-id string (the identity's DisplayName), threaded
/// from <see cref="OnEquipmentTagWrite"/> to populate the audit event's ClientUserId; null when unknown.</param>
internal void RevertOptimisticWriteIfNeeded(
string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus,
string? clientUserId, AddressSpaceRealm realm)
{
// Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released.
AuditWriteUpdateEventState? auditEvent = null;
var key = MapKey(realm, nodeId);
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
// 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
// node-update shape). See the method remarks for the queue-coalescing caveat.
variable.StatusCode = StatusCodes.BadDeviceFailure;
variable.Timestamp = DateTime.UtcNow;
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip
variable.Value = priorValue;
variable.StatusCode = priorStatus;
variable.Timestamp = DateTime.UtcNow;
variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior
// Item C: build the audit event-state while we hold the node reference + Lock, but DON'T report yet.
// Guarded like ReportAuditEvent: the revert above has ALREADY happened, so a surprise from the SDK
// child-population path (e.g. a SetChildValue on a null OldValue/NewValue) must be swallowed + logged,
// never thrown out of this fire-and-forget continuation.
try
{
auditEvent = BuildWriteFailureAuditEvent(variable, outcome, optimisticValue, priorValue, clientUserId);
}
catch (Exception ex)
{
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogError(ex, "OtOpcUaNodeManager: failed to build AuditWriteUpdateEvent for {0}", nodeId);
#pragma warning restore CS0618
auditEvent = null;
}
}
// Report OUTSIDE Lock — keeps the hold short and sidesteps any re-entrancy through the server event path.
if (auditEvent is not null) ReportAuditEvent(auditEvent);
}
/// <summary>
/// Item C — build (but do not report) a Part 8 <see cref="AuditWriteUpdateEventState"/> recording a
/// rejected device write on <paramref name="variable"/>. The caller holds <c>Lock</c> (the node is read
/// here); reporting happens after Lock release. The standard AuditEvent envelope (EventId, EventType,
/// Time/ReceiveTime, ServerId, Severity, Message, SourceNode/SourceName, Status, ActionTimeStamp) is
/// stamped by the SDK's <see cref="AuditEventState.Initialize(ISystemContext, NodeState, EventSeverity,
/// LocalizedText, bool, DateTime)"/> helper; this method then fills the write-specific fields
/// (AttributeId = Value, IndexRange = empty, OldValue = prior, NewValue = attempted) plus ClientUserId.
/// </summary>
/// <param name="variable">The written node (the audit event's SourceNode).</param>
/// <param name="outcome">The failed device-write outcome (its Reason goes into the event Message + Status).</param>
/// <param name="optimisticValue">The value the client attempted (the audit NewValue).</param>
/// <param name="priorValue">The node's real pre-write value (the audit OldValue).</param>
/// <param name="clientUserId">The writing principal's user-id string; null when unknown.</param>
/// <returns>A populated, unreported <see cref="AuditWriteUpdateEventState"/>.</returns>
/// <remarks><c>internal</c> (not private) so <c>NodeManagerWriteRevertTests</c> can assert the populated
/// audit fields at the nearest deterministic seam (the end-to-end <c>Server.ReportEvent</c> dispatch would
/// need a subscribed event monitored-item to observe).</remarks>
internal AuditWriteUpdateEventState BuildWriteFailureAuditEvent(
BaseDataVariableState variable, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue,
string? clientUserId)
{
var reason = string.IsNullOrEmpty(outcome.Reason) ? "device write rejected" : outcome.Reason!;
var audit = new AuditWriteUpdateEventState(null);
// Initialize stamps EventId (fresh GUID bytes), EventType, Time/ReceiveTime, ServerId, Severity, Message,
// SourceNode/SourceName (from `variable`), Status and ActionTimeStamp. The AuditEvent `Status` field is a
// PropertyState<bool> (true=succeeded / false=failed), so the failed device write is recorded as `false`;
// the device's textual Reason is carried in the Message (the StatusCode itself has no audit-field home).
audit.Initialize(
SystemContext,
source: variable,
severity: EventSeverity.Medium,
message: new LocalizedText($"Inbound write rejected by device: {reason}"),
status: false,
actionTimestamp: DateTime.UtcNow);
// Write-specific fields (AuditWriteUpdateEventType). AttributeId = Value; IndexRange empty (full write);
// OldValue = the real pre-write value; NewValue = the value the client attempted. The AuditWriteUpdate
// child PropertyStates are NOT instantiated by Initialize — SetChildValue lazily creates + sets them
// (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);
// 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.
if (clientUserId is not null) audit.SetChildValue(SystemContext, BrowseNames.ClientUserId, clientUserId, false);
var auditEntryId = SystemContext.AuditEntryId;
if (!string.IsNullOrEmpty(auditEntryId))
audit.SetChildValue(SystemContext, BrowseNames.ClientAuditEntryId, auditEntryId, false);
return audit;
}
/// <summary>
/// Item C — report a built audit event through the SDK server, guarding against auditing being disabled
/// / no subscribers / a transient server-event-path failure. A failure here MUST NOT break the revert
/// that already happened, so it is swallowed and logged to the SDK trace (this node manager has no
/// <c>ILogger</c>) rather than propagated. Reported with the node manager's <c>SystemContext</c>, which
/// is what the alarm event path uses too.
/// </summary>
/// <param name="auditEvent">The populated audit event-state to report.</param>
private void ReportAuditEvent(AuditWriteUpdateEventState auditEvent)
{
try
{
Server.ReportEvent(SystemContext, auditEvent);
}
catch (Exception ex)
{
// Auditing disabled / no monitored items / server shutting down ⇒ ReportEvent may no-op or throw;
// either way the revert already stands. Surface a recurring failure in the SDK trace, don't rethrow.
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report AuditWriteUpdateEvent for {0}", auditEvent.SourceNode?.Value);
#pragma warning restore CS0618
}
}
/// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
/// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
/// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) => alarmType switch
{
"OffNormalAlarm" => new OffNormalAlarmState(parent),
"DiscreteAlarm" => new DiscreteAlarmState(parent),
// "LimitAlarm" / "AlarmCondition" / unknown ⇒ base: a script-driven alarm has no OPC limits
// to populate, so the limit subtypes would carry unset High/Low children.
_ => new AlarmConditionState(parent),
};
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
/// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
/// <remarks>
/// Phase C: when a real historian is wired at promotion time (the source is NOT the
/// <see cref="NullHistorianDataSource"/>), the folder ALSO gets the
/// <see cref="EventNotifiers.HistoryRead"/> bit OR-ed in (keeping SubscribeToEvents) and registers
/// its NodeId identifier as an event-history source so the <see cref="HistoryReadEvents"/> override
/// accepts it. The HistoryRead-events bit is therefore only advertised when a historian is wired at
/// the moment of promotion: the Host wires the source at <c>StartAsync</c> — BEFORE any deployment
/// materialises alarms — so the normal boot ordering promotes folders with the bit set. A folder
/// promoted while the source is still Null advertises live-event subscription but NOT event history
/// until the next <see cref="RebuildAddressSpace"/> re-promotes it (acceptable, documented). The
/// HistoryRead bit + source registration happen inside the same first-time
/// <see cref="_notifierFolders"/> block so the idempotency guard covers them too.
/// </remarks>
private void EnsureFolderIsEventNotifier(FolderState folder)
{
if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
if (_historianDataSource is not NullHistorianDataSource)
{
// A historian is wired: advertise event history on this notifier and register it as a source.
// The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName.
folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead);
// Key by the full (namespace-qualified) NodeId string 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;
_eventNotifierSources[MapKey(folder.NodeId)] = sourceName;
}
AddRootNotifier(folder);
folder.ClearChangeMasks(SystemContext, includeChildren: false);
}
/// <summary>Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the
/// <see cref="EventSeverity"/> enum buckets the SDK's <c>SetSeverity</c> expects.</summary>
private static EventSeverity MapSeverity(int severity) => severity switch
{
< 200 => EventSeverity.Low,
< 400 => EventSeverity.MediumLow,
< 600 => EventSeverity.Medium,
< 800 => EventSeverity.MediumHigh,
_ => EventSeverity.High,
};
/// <summary>
/// Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
/// name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
/// Used by <see cref="AddressSpaceApplier"/> to materialise the UNS Area/Line/Equipment
/// folder hierarchy. Idempotent: the second call with the same id returns the cached
/// folder so adding child variables under it still works.
/// </summary>
/// <param name="folderNodeId">The node identifier of the folder.</param>
/// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, folderNodeId);
if (_folders.ContainsKey(key)) return;
lock (Lock)
{
if (_folders.ContainsKey(key)) return;
var parent = ResolveParentFolder(parentNodeId, realm);
var folder = new FolderState(parent)
{
NodeId = new NodeId(folderNodeId, ns),
BrowseName = new QualifiedName(folderNodeId, ns),
DisplayName = displayName,
EventNotifier = EventNotifiers.None,
TypeDefinitionId = ObjectTypeIds.FolderType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
};
parent.AddChild(folder);
AddPredefinedNode(SystemContext, folder);
_folders[key] = folder;
}
}
/// <summary>
/// Update an EXISTING folder node's <see cref="FolderState.DisplayName"/> in place and notify
/// subscribers, WITHOUT a rebuild — the in-place counterpart of <see cref="EnsureFolder"/> for a
/// UNS Area / Line rename. <see cref="EnsureFolder"/> early-returns for an
/// already-present folder id and never touches an existing folder's display name, so a
/// rename-only deploy would otherwise be invisible until a full <see cref="RebuildAddressSpace"/>
/// cleared <c>_folders</c>. The NodeId + BrowseName are unchanged (a rename only touches the
/// friendly display name, not the logical id, so browse-path resolution + ACLs are unaffected).
/// <c>ClearChangeMasks</c> is called so subscribed clients see the new DisplayName immediately,
/// mirroring the <see cref="UpdateTagAttributes"/> surgical path. Returns false when the folder id
/// is unknown (caller falls back to a full rebuild).
/// </summary>
/// <param name="folderNodeId">The node identifier of the folder to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
/// <returns>True when the in-place update was applied; false when the folder id is unknown.</returns>
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
lock (Lock)
{
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return false;
folder.DisplayName = displayName;
folder.ClearChangeMasks(SystemContext, includeChildren: false);
return true;
}
}
/// <summary>
/// Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
/// <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
/// timestamp=epoch — <see cref="WriteValue"/> fills these in once driver data flows.
/// Idempotent. Materialises equipment-namespace tags so they're browseable before drivers
/// issue SubscribeBulk. Note: because of the early <c>_variables.ContainsKey</c> return, a
/// re-apply of an EXISTING node with a changed historize intent (e.g. non-historized →
/// historized) is silently ignored — a historize-intent change only takes effect after a
/// <see cref="RebuildAddressSpace"/> (which the planner triggers on an equipment-tag delta).
/// </summary>
/// <param name="variableNodeId">The node identifier of the variable.</param>
/// <param name="parentFolderNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
/// <param name="writable">When true the node is created <c>CurrentReadWrite</c> (an authored
/// ReadWrite equipment tag) and the inbound-write handler <see cref="OnEquipmentTagWrite"/> is attached
/// to its <c>OnWriteValue</c> so a client write gates on the <c>WriteOperate</c> role + routes
/// to the backing driver; when false it stays <c>CurrentRead</c> (read-only) with no write handler.</param>
/// <param name="historianTagname">Phase C: null ⇒ the node is NOT historized (Historizing=false, no
/// HistoryRead bit, not registered). Non-null ⇒ the node is created <c>Historizing</c> with the
/// <c>HistoryRead</c> access bit OR-ed into both <c>AccessLevel</c> and <c>UserAccessLevel</c>, and the
/// (already default-resolved) tagname is registered in the NodeId→tagname map the HistoryRead override
/// resolves against.</param>
/// <param name="isArray">Phase 4c: when true the node is materialised as a 1-D array
/// (<c>ValueRank=OneDimension</c> with <c>ArrayDimensions=[arrayLength]</c>); when false (default) it
/// stays scalar (<c>ValueRank=Scalar</c>). Array elements share the scalar's base
/// <paramref name="dataType"/> — rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">Phase 4c: the declared length of the 1-D array when
/// <paramref name="isArray"/> is true; ignored for scalars. Null ⇒ length 0.</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
EnsureAddressSpaceCreated();
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, variableNodeId);
// If already present, leave it alone (idempotent re-applies).
if (_variables.ContainsKey(key)) return;
lock (Lock)
{
if (_variables.ContainsKey(key)) return;
var parent = ResolveParentFolder(parentFolderNodeId, realm);
// Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead
// access bit (on top of the writable composite) so clients can browse + HistoryRead it.
var historized = historianTagname is not null;
// 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);
var variable = new BaseDataVariableState(parent)
{
NodeId = new NodeId(variableNodeId, ns),
BrowseName = new QualifiedName(variableNodeId, ns),
DisplayName = displayName,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = ResolveBuiltInDataType(dataType),
ValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
ArrayDimensions = isArray ? new ReadOnlyList<uint>(new UInt32Collection(new[] { arrayLength ?? 0u })) : null,
AccessLevel = access,
UserAccessLevel = access,
Historizing = historized,
Value = null,
StatusCode = StatusCodes.BadWaitingForInitialData,
Timestamp = DateTime.MinValue,
};
// A writable equipment tag owns an inbound-write handler. The SDK invokes
// OnWriteValue on a client write; it gates on the WriteOperate role and routes to the backing
// driver via NodeWriteGateway. Read-only nodes leave the handler unattached so a write is
// rejected by the SDK's own AccessLevel check before it ever reaches a handler.
if (writable)
{
variable.OnWriteValue = OnEquipmentTagWrite;
}
parent.AddChild(variable);
AddPredefinedNode(SystemContext, variable);
_variables[key] = variable;
// Phase C: register the resolved historian tagname so the HistoryRead override can map this
// NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME
// tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
if (historized) _historizedTagnames[key] = historianTagname!;
}
}
/// <summary>Compose the AccessLevel/UserAccessLevel byte for a tag variable from its Writable +
/// Historizing flags — the single source of truth shared by <see cref="EnsureVariable"/> (node
/// creation) and <see cref="UpdateTagAttributes"/> (F10b surgical in-place update). The SDK exposes
/// the flags separately (no CurrentReadWrite composite): ReadWrite is CurrentRead | CurrentWrite,
/// with the HistoryRead bit OR-ed in when historized. OR-ing byte constants promotes to int, so cast back.</summary>
private static byte ComposeAccessLevel(bool writable, bool historized)
{
byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead;
if (historized) access = (byte)(access | AccessLevels.HistoryRead);
return access;
}
/// <summary>F10b surgical counterpart of <see cref="EnsureVariable"/>: update an EXISTING tag variable's
/// Writable (AccessLevel + <see cref="OnEquipmentTagWrite"/> handler), Historizing (+ historian-tagname
/// binding), and presentation shape (<paramref name="dataType"/> → <c>DataType</c>;
/// <paramref name="isArray"/>/<paramref name="arrayLength"/> → <c>ValueRank</c> + <c>ArrayDimensions</c>)
/// in place and notify subscribers, WITHOUT a rebuild — so client MonitoredItems on the node survive.
/// Returns false when the node id is unknown (caller falls back to a full rebuild).
/// <para>
/// When the shape (DataType / ValueRank / ArrayDimensions) ACTUALLY changes, two extra things
/// happen. (1) The node's value is reset to <c>BadWaitingForInitialData</c> (Value=null) so no value
/// still typed to the OLD DataType is exposed between this update and the driver's next publish — the
/// same fresh-node state <see cref="EnsureVariable"/> creates, which closes the "brief value-type
/// mismatch" window. (2) A Part 3 <c>GeneralModelChangeEvent</c> (verb=<c>DataTypeChanged</c>) is
/// raised from the Server object so model-aware clients re-read the node definition. A Writable /
/// Historizing-only change (DataType + array-ness unchanged) skips both — its value stays valid and
/// there is no model change to report (preserves the original surgical behaviour exactly).
/// </para>
/// <para>
/// The model-change event is built under <c>Lock</c> but reported AFTER the lock is released —
/// mirroring <see cref="RevertOptimisticWriteIfNeeded"/>: <c>Server.ReportEvent</c> re-enters the
/// server's own subscription/event path, so holding the node <c>Lock</c> across it risks a
/// lock-order inversion.
/// </para></summary>
/// <param name="variableNodeId">The folder-scoped node id of the variable to update in place.</param>
/// <param name="writable">When true the node becomes read/write with the inbound-write handler; otherwise read-only.</param>
/// <param name="historianTagname">null ⇒ not historized; non-null ⇒ Historizing with the HistoryRead bit and tagname binding.</param>
/// <param name="dataType">The OPC UA built-in data type name to apply (e.g. "Boolean", "Int32", "Float").</param>
/// <param name="isArray">When true the node becomes a 1-D array (ValueRank=OneDimension); when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
/// <returns>True when the in-place update was applied; false when the node id is unknown.</returns>
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(dataType); // widened surface ⇒ explicit contract (unknown names still map to BaseDataType)
var key = MapKey(realm, variableNodeId);
BaseDataVariableState? shapeChangedNode = null;
lock (Lock)
{
if (!_variables.TryGetValue(key, out var v)) return false;
var historized = historianTagname is not null;
var access = ComposeAccessLevel(writable, historized);
v.AccessLevel = access;
v.UserAccessLevel = access;
v.Historizing = historized;
v.OnWriteValue = writable ? OnEquipmentTagWrite : null;
if (historized) _historizedTagnames[key] = historianTagname!;
else _historizedTagnames.TryRemove(key, out _);
// Swap DataType + ValueRank + ArrayDimensions in place, but only when they actually differ
// from the live node — a Writable/Historizing-only change leaves the shape untouched (no value
// reset, no model-change event), preserving the original surgical behaviour byte-for-byte.
var newDataType = ResolveBuiltInDataType(dataType);
var newValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar;
if (v.DataType != newDataType || v.ValueRank != newValueRank || ArrayLengthDiffers(v, isArray, arrayLength))
{
v.DataType = newDataType;
v.ValueRank = newValueRank;
v.ArrayDimensions = isArray
? new ReadOnlyList<uint>(new UInt32Collection(new[] { arrayLength ?? 0u }))
: null;
// 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.StatusCode = StatusCodes.BadWaitingForInitialData;
v.Timestamp = DateTime.MinValue;
shapeChangedNode = v;
}
v.ClearChangeMasks(SystemContext, includeChildren: false);
}
// Report OUTSIDE Lock (see the method remarks) — only when the shape actually changed.
if (shapeChangedNode is not null) ReportNodeShapeChangedEvent(shapeChangedNode);
return true;
}
/// <summary>True when an array target's requested 1-D length differs from the node's current one. This is
/// the array-to-array length-edit case: the caller's <c>ValueRank != newValueRank</c> check already catches
/// any scalar↔array transition, so this runs to decide whether an already-array node's dimension changed
/// (and treats absent/empty current dimensions as "differs", forcing the in-place update).</summary>
private static bool ArrayLengthDiffers(BaseDataVariableState v, bool isArray, uint? arrayLength)
{
if (!isArray) return false; // scalar target ⇒ ValueRank check owns the diff
if (v.ArrayDimensions is not { Count: > 0 }) return true; // no current dimension ⇒ shape differs
return v.ArrayDimensions[0] != (arrayLength ?? 0u);
}
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that
/// <paramref name="variable"/>'s definition changed (DataType / ValueRank / ArrayDimensions). Verb =
/// <c>DataTypeChanged</c> — the model-change verb the SDK exposes for an attribute-shape change (there is
/// no separate ValueRank verb). <c>internal</c> (not private) so a node-manager test can assert the
/// populated Changes structure at the nearest deterministic seam (the end-to-end <c>Server.ReportEvent</c>
/// dispatch would need a subscribed event monitored-item to observe).</summary>
/// <param name="variable">The variable whose shape changed.</param>
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
internal GeneralModelChangeEventState BuildNodeShapeChangedEvent(BaseDataVariableState variable)
{
var e = new GeneralModelChangeEventState(null);
e.Initialize(
SystemContext,
source: null,
severity: EventSeverity.Medium,
message: new LocalizedText($"Node {variable.NodeId} definition changed (DataType/ValueRank)"));
// Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object — set SourceNode/SourceName
// to Server explicitly (we report with source:null since this manager has no Server NodeState handle),
// so conformant clients that filter events by SourceNode still match this one.
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
var change = new ModelChangeStructureDataType
{
Affected = variable.NodeId,
AffectedType = variable.TypeDefinitionId,
Verb = (byte)ModelChangeStructureVerbMask.DataTypeChanged,
};
// 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);
return e;
}
/// <summary>Report the built <c>GeneralModelChangeEvent</c> through the SDK server, guarding against the
/// event path being disabled / having no subscribers / a transient failure — a surprise here MUST NOT
/// break the in-place update that already happened (mirrors <see cref="ReportAuditEvent"/>).</summary>
/// <param name="variable">The variable whose shape changed.</param>
private void ReportNodeShapeChangedEvent(BaseDataVariableState variable)
{
try
{
Server.ReportEvent(SystemContext, BuildNodeShapeChangedEvent(variable));
}
catch (Exception ex)
{
// Model-change reporting disabled / no monitored items / server shutting down ⇒ ReportEvent may
// no-op or throw; either way the in-place update already stands. Log to the SDK trace, don't rethrow.
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {0}", variable.NodeId);
#pragma warning restore CS0618
}
}
/// <summary>
/// Emit a Part 3 <c>GeneralModelChangeEvent</c> (verb <c>NodeAdded</c>) announcing that one or more
/// nodes were added UNDER <paramref name="affectedNodeId"/> at runtime — so already-connected,
/// model-aware OPC UA clients re-browse the affected node and discover the new children. This is the
/// runtime-add counterpart of the shape-changed reporter (<see cref="ReportNodeShapeChangedEvent"/>):
/// when a driver discovers FixedTree nodes AFTER the server is up and they are materialised into the
/// served Equipment address space (Tasks 4/5), an attribute notification alone is invisible to a
/// subscribed client — only a model-change event tells it the address space grew.
/// <para>
/// The event is built under <c>Lock</c> but reported AFTER the lock is released, mirroring
/// <see cref="ReportNodeShapeChangedEvent"/> / <see cref="RevertOptimisticWriteIfNeeded"/>:
/// <c>Server.ReportEvent</c> re-enters the server's own subscription/event path, so holding the node
/// <c>Lock</c> across it risks a lock-order inversion with a client that has event subscriptions.
/// The report is wrapped in try/catch so it is tolerant when eventing is disabled / there are no
/// monitored items / the server is shutting down — the same swallow-and-log tolerance as the
/// write-revert path (<see cref="ReportAuditEvent"/>). The nodes have already been materialised, so
/// a surprise from the event path MUST NOT propagate out of this announcement.
/// </para>
/// </summary>
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(affectedNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
e = BuildNodesAddedModelChange(affectedNodeId, realm);
}
// Report OUTSIDE Lock — Server.ReportEvent re-enters the server's own subscription/event path; holding
// Lock across it risks a lock-order inversion (mirrors ReportNodeShapeChangedEvent).
try
{
Server.ReportEvent(SystemContext, e);
}
catch (Exception ex)
{
// Model-change reporting disabled / no monitored items / server shutting down ⇒ ReportEvent may
// no-op or throw; either way the node add already stands. Log to the SDK trace, don't rethrow.
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent(NodeAdded) for {0}", affectedNodeId);
#pragma warning restore CS0618
}
}
/// <inheritdoc cref="IOpcUaAddressSpaceSink.AddReference" />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
string targetNodeId, AddressSpaceRealm targetRealm,
string referenceType = "Organizes")
{
ArgumentException.ThrowIfNullOrEmpty(sourceNodeId);
ArgumentException.ThrowIfNullOrEmpty(targetNodeId);
EnsureAddressSpaceCreated();
var refType = ResolveReferenceType(referenceType);
var sourceKey = MapKey(sourceRealm, sourceNodeId);
var targetKey = MapKey(targetRealm, targetNodeId);
lock (Lock)
{
var source = ResolveNodeState(sourceKey);
var target = ResolveNodeState(targetKey);
// A missing endpoint is a no-op (logged) — the applier may drive the cross-tree link while a
// concurrent rebuild has torn one side down; never throw out of a deploy-time reference wire.
if (source is null || target is null)
{
#pragma warning disable CS0618 // Utils.LogWarning is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogWarning(
"OtOpcUaNodeManager: AddReference({0}) skipped — {1} node missing (source '{2}' present={3}, target '{4}' present={5}).",
refType, source is null ? "source" : "target", sourceKey, source is not null, targetKey, target is not null);
#pragma warning restore CS0618
return;
}
// Idempotent: a re-apply must not duplicate the edge. Organizes is hierarchical, so wire it
// bidirectionally — forward on the source, inverse (OrganizedBy) on the target — so browse
// resolves both ways. Only ClearChangeMasks a side we actually mutated.
var sourceChanged = false;
var targetChanged = false;
if (!source.ReferenceExists(refType, isInverse: false, target.NodeId))
{
source.AddReference(refType, isInverse: false, target.NodeId);
sourceChanged = true;
}
if (!target.ReferenceExists(refType, isInverse: true, source.NodeId))
{
target.AddReference(refType, isInverse: true, source.NodeId);
targetChanged = true;
}
if (sourceChanged) source.ClearChangeMasks(SystemContext, includeChildren: false);
if (targetChanged) target.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
/// <summary>Resolve a materialised node (variable, folder, or alarm condition) by its full
/// (namespace-qualified) map key, or null when no node is registered under that key. Used by
/// <see cref="AddReference"/> to link two already-materialised nodes regardless of their kind.</summary>
/// <param name="key">The namespace-qualified map key (see <see cref="MapKey(AddressSpaceRealm, string)"/>).</param>
/// <returns>The node state, or null when unknown.</returns>
private NodeState? ResolveNodeState(string key)
{
if (_variables.TryGetValue(key, out var variable)) return variable;
if (_folders.TryGetValue(key, out var folder)) return folder;
if (_alarmConditions.TryGetValue(key, out var condition)) return condition;
return null;
}
/// <summary>Test/diagnostic accessor: snapshot the references on a materialised node (by realm-qualified
/// id) using the manager's own <c>SystemContext</c>, or an empty list when the node is unknown. Exposed
/// so <c>SdkAddressSpaceSinkTests</c> can assert the cross-tree <c>Organizes</c> edge
/// <see cref="AddReference"/> wires without needing the (protected) SDK system context.</summary>
/// <param name="nodeId">The node's <c>s=</c> id.</param>
/// <param name="realm">The realm the node lives in.</param>
/// <returns>The node's references, or an empty list when the node is not registered.</returns>
internal IReadOnlyList<IReference> GetNodeReferences(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (Lock)
{
var node = ResolveNodeState(MapKey(realm, nodeId));
if (node is null) return Array.Empty<IReference>();
var list = new List<IReference>();
node.GetReferences(SystemContext, list);
return list;
}
}
/// <summary>Map a hierarchical reference-type name to its SDK <see cref="NodeId"/>. Unknown names fall
/// back to <see cref="ReferenceTypeIds.Organizes"/> (the UNS→Raw cross-tree link).</summary>
/// <param name="referenceType">The reference-type name (e.g. "Organizes").</param>
/// <returns>The reference-type NodeId.</returns>
private static NodeId ResolveReferenceType(string referenceType) => referenceType switch
{
"HasComponent" => ReferenceTypeIds.HasComponent,
"HasProperty" => ReferenceTypeIds.HasProperty,
_ => ReferenceTypeIds.Organizes,
};
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that nodes were
/// added under <paramref name="affectedNodeId"/>. MIRRORS <see cref="BuildNodeShapeChangedEvent"/> exactly —
/// the only differences are <c>Verb = NodeAdded</c> (vs <c>DataTypeChanged</c>) and <c>Affected</c> = the
/// passed parent node id (vs the variable's own NodeId). <c>AffectedType</c> carries the affected node's
/// TypeDefinition resolved from the live node maps (same semantics the shape-changed builder gets from the
/// variable), defaulting to <see cref="NodeId.Null"/> when the id is not (yet) materialised. <c>internal</c>
/// (not private) so a node-manager test can assert the populated Changes structure at the nearest
/// deterministic seam (the end-to-end <c>Server.ReportEvent</c> dispatch would need a subscribed event
/// monitored-item to observe).</summary>
/// <param name="affectedNodeId">The folder-scoped node id of the parent under which nodes were added.</param>
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
internal GeneralModelChangeEventState BuildNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm));
var e = new GeneralModelChangeEventState(null);
e.Initialize(
SystemContext,
source: null,
severity: EventSeverity.Medium,
message: new LocalizedText($"Nodes added under {affected}"));
// Part 3 §8.7.4: a GeneralModelChangeEvent is emitted by the Server object — set SourceNode/SourceName
// to Server explicitly (we report with source:null since this manager has no Server NodeState handle),
// so conformant clients that filter events by SourceNode still match this one.
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
var change = new ModelChangeStructureDataType
{
Affected = affected,
// The affected node is the parent the children were added under; carry its TypeDefinition (a Folder
// for an equipment parent) just as the shape-changed builder carries the variable's. Null when the
// id is unknown — a valid Part 3 "type not applicable", and clients re-browse Affected regardless.
AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm),
Verb = (byte)ModelChangeStructureVerbMask.NodeAdded,
};
// 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);
return e;
}
/// <summary>Build (but do not report) the Part 3 <c>GeneralModelChangeEvent</c> announcing that the node
/// at <paramref name="affectedNodeId"/> was DELETED. Mirrors <see cref="BuildNodesAddedModelChange"/>
/// exactly — the only differences are <c>Verb = NodeDeleted</c> and that the affected node has already
/// been dropped from the maps, so <c>AffectedType</c> resolves to <see cref="NodeId.Null"/> (a valid Part 3
/// "type not applicable"; clients re-browse the parent regardless). <c>internal</c> so a node-manager test
/// can assert the populated Changes structure at the nearest deterministic seam.</summary>
/// <param name="affectedNodeId">The node id of the deleted node.</param>
/// <returns>A populated, unreported <see cref="GeneralModelChangeEventState"/>.</returns>
internal GeneralModelChangeEventState BuildNodesRemovedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
var affected = new NodeId(affectedNodeId, NamespaceIndexForRealm(realm));
var e = new GeneralModelChangeEventState(null);
e.Initialize(
SystemContext,
source: null,
severity: EventSeverity.Medium,
message: new LocalizedText($"Node deleted: {affected}"));
// Part 3 §8.7.4: emitted by the Server object — set SourceNode/SourceName to Server explicitly
// (mirrors BuildNodesAddedModelChange).
e.SetChildValue(SystemContext, BrowseNames.SourceNode, ObjectIds.Server, false);
e.SetChildValue(SystemContext, BrowseNames.SourceName, "Server", false);
var change = new ModelChangeStructureDataType
{
Affected = affected,
// The node is already gone from the maps, so its TypeDefinition is not applicable (Null).
AffectedType = ResolveAffectedTypeDefinition(affectedNodeId, realm),
Verb = (byte)ModelChangeStructureVerbMask.NodeDeleted,
};
e.SetChildValue(SystemContext, BrowseNames.Changes, new[] { change }, false);
return e;
}
/// <summary>Report a pre-built <see cref="GeneralModelChangeEventState"/> OUTSIDE <c>Lock</c> —
/// <c>Server.ReportEvent</c> re-enters the server's own subscription/event path, so holding <c>Lock</c>
/// across it risks a lock-order inversion (mirrors <see cref="RaiseNodesAddedModelChange"/> /
/// <c>ReportNodeShapeChangedEvent</c>). Tolerant: swallow-and-log when eventing is disabled / there are no
/// monitored items / the server is shutting down — the node mutation already stands.</summary>
/// <param name="e">The pre-built event to report.</param>
/// <param name="affectedNodeId">The affected node id (for the diagnostic only).</param>
private void ReportModelChangeOutsideLock(GeneralModelChangeEventState e, string affectedNodeId)
{
try
{
Server.ReportEvent(SystemContext, e);
}
catch (Exception ex)
{
#pragma warning disable CS0618 // Utils.LogError is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogError(ex, "OtOpcUaNodeManager: failed to report GeneralModelChangeEvent for {0}", affectedNodeId);
#pragma warning restore CS0618
}
}
/// <summary>Resolve the TypeDefinition of a materialised node id from the live folder/variable maps for a
/// model-change event's <c>AffectedType</c>; <see cref="NodeId.Null"/> when the id is not registered.</summary>
/// <param name="nodeId">The folder-scoped node id whose TypeDefinition is wanted.</param>
private NodeId ResolveAffectedTypeDefinition(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
var key = MapKey(realm, nodeId);
if (_folders.TryGetValue(key, out var folder)) return folder.TypeDefinitionId;
if (_variables.TryGetValue(key, out var variable)) return variable.TypeDefinitionId;
return NodeId.Null;
}
/// <summary>Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String",
/// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType
/// (matches CreateVariable's default for lazy-created nodes).</summary>
private static NodeId ResolveBuiltInDataType(string dataType) => dataType switch
{
"Boolean" => DataTypeIds.Boolean,
"SByte" => DataTypeIds.SByte,
"Byte" => DataTypeIds.Byte,
"Int16" => DataTypeIds.Int16,
"UInt16" => DataTypeIds.UInt16,
"Int32" => DataTypeIds.Int32,
"UInt32" => DataTypeIds.UInt32,
"Int64" => DataTypeIds.Int64,
"UInt64" => DataTypeIds.UInt64,
"Float" => DataTypeIds.Float,
"Double" => DataTypeIds.Double,
"String" => DataTypeIds.String,
"DateTime" => DataTypeIds.DateTime,
_ => DataTypeIds.BaseDataType,
};
/// <summary>Clear every registered variable + folder from the address space. AddressSpaceApplier
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
/// EnsureFolder + WriteValue on the next pass.</summary>
public void RebuildAddressSpace()
{
lock (Lock)
{
foreach (var v in _variables.Values)
{
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_variables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear();
foreach (var alarm in _alarmConditions.Values)
{
alarm.Parent?.RemoveChild(alarm);
PredefinedNodes?.Remove(alarm.NodeId);
}
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
// rebuild. The condition + folder NodeState objects still exist here (the loops above/below only
// detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the
// tracking map so the re-materialise + re-wire on the next apply starts from a clean slate.
foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList())
UnwireAlarmNotifiers(alarmKey);
_alarmConditions.Clear();
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
_nativeAlarmNodeIds.Clear();
foreach (var f in _folders.Values)
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
_folders.Clear();
// Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the
// guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server
// object. RemoveRootNotifier just severs that link, so its order relative to the folder
// teardown above doesn't matter — but it must run under this same Lock.
foreach (var folder in _notifierFolders.Values)
{
RemoveRootNotifier(folder);
}
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
// equipment folders to event notifiers.
_notifierFolders.Clear();
// Phase C: drop the event-history source registrations alongside the notifier folders
// they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion.
_eventNotifierSources.Clear();
}
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
EnsureAddressSpaceCreated();
var key = MapKey(realm, variableNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown id ⇒ the node-manager maps drifted from what the planner believes; return false so the
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
// teardown inside RebuildAddressSpace, scoped to this one id.
if (!_variables.TryRemove(key, out var variable)) return false;
variable.Parent?.RemoveChild(variable);
PredefinedNodes?.Remove(variable.NodeId);
// Drop the historized-tagname registration alongside the variable it maps (Phase C parity).
_historizedTagnames.TryRemove(key, out _);
e = BuildNodesRemovedModelChange(variableNodeId, realm);
}
ReportModelChangeOutsideLock(e, variableNodeId);
return true;
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
EnsureAddressSpaceCreated();
var key = MapKey(realm, alarmNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown id ⇒ map drift ⇒ false (caller rebuilds). Mirrors the condition teardown inside
// RebuildAddressSpace, scoped to this one condition. The equipment folder's event-notifier
// promotion is intentionally NOT demoted here — other conditions may still hang under it, and a
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
// equipment goes).
if (!_alarmConditions.TryRemove(key, out var condition)) return false;
// v3 Batch 4 WP4: unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
// the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without
// this they would keep a dangling inverse-notifier entry to the removed condition.
UnwireAlarmNotifiers(key);
condition.Parent?.RemoveChild(condition);
PredefinedNodes?.Remove(condition.NodeId);
_nativeAlarmNodeIds.Remove(key);
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
}
ReportModelChangeOutsideLock(e, alarmNodeId);
return true;
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
EnsureAddressSpaceCreated();
var equipKey = MapKey(realm, equipmentNodeId);
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
if (!_folders.ContainsKey(equipKey)) return false;
// Maps key on the full namespace-qualified NodeId string (e.g. "ns=3;s=<equipmentId>"). Every
// descendant in the SAME realm shares that key exactly or begins with "<equipKey>/" (sub-folders,
// variables, condition nodes); the "ns=N;s=" prefix makes the match realm-scoped for free, so a
// node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot each
// map's in-scope keys first (ToList) so we don't mutate while enumerating.
var prefix = equipKey + "/";
bool InScope(string key) => key == equipKey || key.StartsWith(prefix, StringComparison.Ordinal);
// Value variables (+ their historized-tagname registrations).
foreach (var id in _variables.Keys.Where(InScope).ToList())
{
if (_variables.TryRemove(id, out var v))
{
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_historizedTagnames.TryRemove(id, out _);
}
// Part 9 condition nodes (+ their native flags).
foreach (var id in _alarmConditions.Keys.Where(InScope).ToList())
{
if (_alarmConditions.TryRemove(id, out var c))
{
c.Parent?.RemoveChild(c);
PredefinedNodes?.Remove(c.NodeId);
}
_nativeAlarmNodeIds.Remove(id);
}
// v3 Batch 4 WP4: a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this
// UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra
// event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop,
// else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The
// condition's own scoped removal — if its raw tag is also removed this apply — runs later via
// RemoveAlarmConditionNode; this only reaches surviving conditions.)
foreach (var wiring in _alarmNotifierWiring.Values)
{
foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList())
{
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
wiring.Folders.Remove(folder);
}
}
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
// _notifierFolders is keyed by the folder's NodeId; match InScope on its full (ns-qualified) string.
foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.ToString())).ToList())
{
RemoveRootNotifier(kvp.Value);
_notifierFolders.Remove(kvp.Key);
}
// Drop the event-history source registrations for the subtree (keyed by the folder id string).
foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList())
_eventNotifierSources.TryRemove(src, out _);
// Sub-folders + the equipment folder itself (children already detached above).
foreach (var id in _folders.Keys.Where(InScope).ToList())
{
if (_folders.TryRemove(id, out var f))
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
}
e = BuildNodesRemovedModelChange(equipmentNodeId, realm);
}
ReportModelChangeOutsideLock(e, equipmentNodeId);
return true;
}
private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm)
{
EnsureAddressSpaceCreated();
if (string.IsNullOrEmpty(parentNodeId)) return _root!;
// A node's parent lives in the SAME realm (a raw tag's group is raw; a UNS reference's equipment
// folder is UNS), so resolve the parent key in that realm; unknown parent ⇒ hang under the shared root.
return _folders.TryGetValue(MapKey(realm, parentNodeId), out var existing) ? existing : _root!;
}
/// <summary>Guard the address-space mutators against a too-early call. <c>_root</c>
/// is only assigned in <see cref="CreateAddressSpace"/>, which the SDK invokes during
/// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>,
/// <see cref="WriteAlarmCondition"/>, <see cref="EnsureFolder"/>, <see cref="EnsureVariable"/>,
/// <see cref="MaterialiseAlarmCondition"/>) and <see cref="ResolveParentFolder"/> assume it has run.
/// If one is invoked before the server has started (a sink wired or a publish replayed before
/// <c>StartAsync</c> completes), <c>_root</c> is null and the dereference would NRE; throw a legible
/// <see cref="InvalidOperationException"/> instead. Happy-path behaviour is unchanged.</summary>
/// <exception cref="InvalidOperationException">When the address space has not been created yet.</exception>
private void EnsureAddressSpaceCreated()
{
if (_root is null)
{
throw new InvalidOperationException(
"OPC UA address space has not been created yet (server not started).");
}
}
// ---------------------------------------------------------------------------------------------
// Phase C — OPC UA HistoryRead over historized variable nodes.
//
// The base CustomNodeManager2.HistoryRead (public + protected dispatcher) does the heavy lifting:
// it validates handles under Lock, builds `nodesToProcess` (a NodeHandle list for nodes WE own
// that carry the HistoryRead access bit), validates the timestamp args, handles
// `releaseContinuationPoints`, and dispatches by `details` runtime type to the per-details
// protected virtuals below. We override all four arms: the three variable-history virtuals
// (Raw/Processed/AtTime) and the event-history arm (HistoryReadEvents). Each override
// receives the pre-filtered handles and fills results[handle.Index] / errors[handle.Index] —
// handle.Index is the original index into the service-level results/errors lists, seeded by the
// base. The base pre-seeds every handle's error to BadHistoryOperationUnsupported, so a handle
// we don't recognise stays "unsupported" by default.
//
// NOTE: unlike OnWriteValue, the SDK does NOT hold the node-manager Lock while invoking these, so
// block-bridging the async data source (GetAwaiter().GetResult()) is safe — it can't freeze the
// address space. Each handle is served in isolation under try/catch so one node's failure (timeout,
// backend throw) never throws out of the batch.
// ---------------------------------------------------------------------------------------------
/// <inheritdoc />
protected override void HistoryReadRawModified(
ServerSystemContext context,
ReadRawModifiedDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
var session = context.OperationContext?.Session;
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
if (details.IsReadModified)
{
// We never serve modified-value history; mark this node unsupported and move on.
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
continue;
}
work.Add(() => ServeRawPagedAsync(
handle, session, nodesToRead, results, errors,
details.StartTime, details.EndTime, details.NumValuesPerNode,
ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
/// <inheritdoc />
protected override void HistoryReadProcessed(
ServerSystemContext context,
ReadProcessedDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
// the base dispatcher). handle.Index is the node's position in that collection.
var aggregateNodeId = details.AggregateType[handle.Index];
var aggregate = MapAggregate(aggregateNodeId);
if (aggregate is null)
{
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
continue;
}
// Processed is SINGLE-SHOT (no continuation point). Unlike Raw, ReadProcessedDetails carries
// NO client count cap (NumValuesPerNode) — the bucket count is deterministic (window / interval)
// and the single-shot backend returns every bucket in one read, so there is no "full page ⇒
// maybe more" signal to page on. Returning the complete aggregate result with a null CP is
// spec-conformant (OPC UA Part 11 lets a server return all available data in one response).
var agg = aggregate.Value;
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
tagname,
details.StartTime,
details.EndTime,
interval,
agg,
token), ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
/// <inheritdoc />
protected override void HistoryReadAtTime(
ServerSystemContext context,
ReadAtTimeDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
// Snapshot the requested timestamps once — the same list is read for every node.
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync(
tagname,
timestamps,
token), ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
/// <inheritdoc />
protected override void HistoryReadEvents(
ServerSystemContext context,
ReadEventDetails details,
TimestampsToReturn timestampsToReturn,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
// Snapshot the select clauses once — the same filter projects every node's events.
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
{
using var deadline = CreateDeadline();
var ct = deadline?.Token ?? CancellationToken.None;
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
// Key on the full (namespace-qualified) NodeId so a Raw and a UNS notifier folder that share a
// bare id resolve to their own source registration.
if (!_eventNotifierSources.TryGetValue(MapKey(handle.NodeId), out var sourceName))
{
// Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported.
// Set both errors and results explicitly on every bad path — don't rely on the SDK base
// pre-seeding results[i], so every path is self-contained and the contract is obvious.
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
continue;
}
var sn = sourceName;
work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, results, errors, ct));
}
RunBounded(work, HistoryReadBatchParallelism);
});
}
/// <summary>
/// Serve one registered event-history notifier handle: reads the source's events over the request
/// window, projects them onto the select clauses, and fills the service-level results/errors slots.
/// Per-node error isolation matches <see cref="ServeNodeAsync"/> — a backend throw becomes a Bad
/// status for THIS node only and never throws out of the batch; a deadline cancellation surfaces
/// <c>BadTimeout</c>.
/// </summary>
/// <param name="handle">The notifier node handle; <c>handle.Index</c> indexes results/errors.</param>
/// <param name="sourceName">The registered historian event-source name for this notifier.</param>
/// <param name="details">The event-read details (window + count cap).</param>
/// <param name="selectClauses">The event-filter select clauses (the fields to emit, in order).</param>
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
/// <param name="ct">Per-request deadline token.</param>
private async Task ServeEventsAsync(
NodeHandle handle,
string? sourceName,
ReadEventDetails details,
SimpleAttributeOperandCollection selectClauses,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
CancellationToken ct)
{
try
{
// NOT under the node-manager Lock — awaiting the async source is safe here.
var sourceResult = await _historianDataSource.ReadEventsAsync(
sourceName,
details.StartTime,
details.EndTime,
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA
// Part 4/11, so translate it to UNBOUNDED (int.MaxValue) here. Passing the int<=0
// backend-default-cap sentinel instead would silently truncate a "give me everything"
// events read at the backend default. A positive cap passes through (clamped).
EventMaxEvents(details.NumValuesPerNode),
ct).ConfigureAwait(false);
var historyEvent = ProjectEvents(sourceResult.Events, selectClauses);
results[handle.Index] = new SdkHistoryReadResult
{
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
StatusCode = sourceResult.Events.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyEvent),
// We never issue continuation points — every read returns the full window in one shot.
ContinuationPoint = null,
};
errors[handle.Index] = ServiceResult.Good;
}
catch (OperationCanceledException)
{
// The per-request deadline elapsed while reading this notifier — BadTimeout for THIS node only.
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (Exception ex)
{
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
// only. This manager carries no ILogger, so log via the SDK's static trace (see ServeNodeAsync).
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogError(ex, "OtOpcUaNodeManager: HistoryReadEvents failed for node {0}", handle.NodeId);
#pragma warning restore CS0618
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
}
}
/// <summary>Clamp a <see cref="uint"/> request cap to a non-negative <see cref="int"/> for the
/// event-read surface (whose <c>maxEvents</c> is signed): values above <see cref="int.MaxValue"/>
/// saturate to <see cref="int.MaxValue"/>.</summary>
/// <param name="value">The uint cap from the request (<c>NumValuesPerNode</c>).</param>
/// <returns>The clamped non-negative int.</returns>
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
/// <summary>Map a HistoryRead-Events <c>NumValuesPerNode</c> request cap onto the
/// <see cref="IHistorianDataSource.ReadEventsAsync"/> <c>maxEvents</c> argument, honouring the OPC UA
/// Part 4/11 semantics that <c>NumValuesPerNode == 0</c> means "no limit — return ALL values".
/// We translate 0 to UNBOUNDED (<see cref="int.MaxValue"/>) — a very large positive cap — rather than
/// the backend's <c>maxEvents &lt;= 0</c> "use the default cap" sentinel, so a client asking for the
/// whole window is not silently truncated at the backend default. A positive value passes through
/// clamped to <see cref="int.MaxValue"/> (mirroring <see cref="ClampToInt"/>).</summary>
/// <param name="numValuesPerNode">The request's <c>NumValuesPerNode</c> cap (0 ⇒ no limit).</param>
/// <returns><see cref="int.MaxValue"/> when 0 (unbounded); otherwise the clamped non-negative int.</returns>
internal static int EventMaxEvents(uint numValuesPerNode) =>
numValuesPerNode == 0 ? int.MaxValue : ClampToInt(numValuesPerNode);
/// <summary>
/// Project a sequence of <see cref="HistoricalEvent"/>s into an SDK <see cref="HistoryEvent"/> —
/// one <see cref="HistoryEventFieldList"/> per event, each carrying the requested
/// <paramref name="selectClauses"/>' fields in select-clause order (see
/// <see cref="ProjectEventField"/>).
/// </summary>
/// <param name="events">The historian's event rows.</param>
/// <param name="selectClauses">The request's event-filter select clauses (the fields to emit, in order).</param>
/// <returns>The populated SDK <see cref="HistoryEvent"/>.</returns>
private static HistoryEvent ProjectEvents(
IReadOnlyList<HistoricalEvent> events, SimpleAttributeOperandCollection selectClauses)
{
var fieldLists = new HistoryEventFieldListCollection(events.Count);
foreach (var evt in events)
{
var fields = new VariantCollection(selectClauses.Count);
foreach (var operand in selectClauses)
{
fields.Add(ProjectEventField(evt, operand));
}
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
}
return new HistoryEvent { Events = fieldLists };
}
/// <summary>
/// Project one <see cref="HistoricalEvent"/> field requested by a select <paramref name="operand"/>
/// into a <see cref="Variant"/>. Mapping is by the operand's BrowsePath LEAF QualifiedName name
/// (case-sensitive, per the OPC UA BaseEventType field names) against the
/// <see cref="HistoricalEvent"/> shape:
/// <c>EventId</c>→ByteString, <c>SourceName</c>→String, <c>Time</c>→DateTime,
/// <c>ReceiveTime</c>→DateTime, <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16. Any other
/// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
/// <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
/// </summary>
/// <param name="evt">The source event row.</param>
/// <param name="operand">The select-clause operand naming the field to project.</param>
/// <returns>The projected variant, or <see cref="Variant.Null"/> for an unsupported field.</returns>
private static Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand)
{
var leaf = LeafFieldName(operand);
return leaf switch
{
// BaseEventType/EventId is a ByteString — encode the driver-specific string id as UTF-8 bytes.
"EventId" => new Variant(System.Text.Encoding.UTF8.GetBytes(evt.EventId ?? string.Empty)),
"SourceName" => evt.SourceName is null ? Variant.Null : new Variant(evt.SourceName),
"Time" => new Variant(evt.EventTimeUtc),
"ReceiveTime" => new Variant(evt.ReceivedTimeUtc),
"Message" => new Variant(new LocalizedText(evt.Message ?? string.Empty)),
"Severity" => new Variant(evt.Severity), // UInt16
// EventType / SourceNode / ConditionName / empty path / unrecognised leaf ⇒ null (spec-conformant).
_ => Variant.Null,
};
}
/// <summary>Extract the leaf (last) <see cref="QualifiedName.Name"/> from a select operand's BrowsePath,
/// or <c>null</c> when the BrowsePath is null/empty.</summary>
/// <param name="operand">The select-clause operand.</param>
/// <returns>The leaf field name, or null for an empty BrowsePath.</returns>
private static string? LeafFieldName(SimpleAttributeOperand operand)
{
var path = operand.BrowsePath;
if (path is null || path.Count == 0) return null;
return path[^1].Name;
}
/// <summary>
/// Block-bridge to the historian source for one node handle and project the result onto the
/// service-level results/errors slots. Resolves the node's registered historian tagname first —
/// a single <see cref="TryGetHistorizedTagname"/> lookup; the resolved tagname is passed directly
/// to <paramref name="read"/>, removing any risk of a second concurrent lookup on the same key.
/// A node we don't recognise as historized maps to <c>BadHistoryOperationUnsupported</c>
/// (shouldn't normally reach us, since the base only hands us nodes with the HistoryRead access
/// bit, but we guard explicitly). The <paramref name="read"/> callback receives the resolved
/// tagname and is wrapped in try/catch so a backend throw / timeout becomes a Bad status for
/// THIS node without throwing out of the batch.
/// </summary>
/// <param name="handle">The pre-filtered node handle to serve; <c>handle.Index</c> indexes results/errors.</param>
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
/// <param name="read">Invokes the resolved data-source read with the resolved tagname; only called
/// once the tagname is confirmed present.</param>
/// <summary>
/// Run a batch of per-node HistoryRead work items with BOUNDED parallelism (arch-review 03/S3),
/// block-bridged ONCE at the arm boundary. <b>Contract:</b> every work item must be fully
/// self-contained — it fills its own results/errors slot and NEVER throws (all per-node handlers
/// catch every exception, including cancellation, and surface a Bad status for that node only). That
/// invariant is what makes the single <c>GetAwaiter().GetResult()</c> safe: <see cref="Task.WhenAll(Task[])"/>
/// can never fault, so it cannot throw out of the synchronous SDK override. A degree ≤ 1 (or a
/// single item) runs sequentially — no semaphore, no extra tasks.
/// </summary>
/// <param name="work">The per-node work items (index-disjoint writes into the shared results/errors).</param>
/// <param name="degree">Max concurrent work items (<see cref="HistoryReadBatchParallelism"/>).</param>
private static void RunBounded(IReadOnlyList<Func<Task>> work, int degree)
{
if (work.Count == 0) return;
if (degree <= 1 || work.Count == 1)
{
foreach (var item in work)
item().GetAwaiter().GetResult();
return;
}
using var gate = new SemaphoreSlim(degree);
var tasks = new Task[work.Count];
for (var i = 0; i < work.Count; i++)
tasks[i] = RunGatedAsync(gate, work[i]);
// Safe: every body is self-contained + never throws, so WhenAll never faults (see contract above).
Task.WhenAll(tasks).GetAwaiter().GetResult();
}
/// <summary>
/// Run one HistoryRead arm under the process-wide concurrent-batch limiter (arch-review 03/S3). Each
/// arm waits up to <c>min(HistoryReadDeadline, 5 s)</c> for a slot; on expiry every handle in the
/// batch gets <c>BadTooManyOperations</c> (fast-fail under flood, brief waits under mild contention)
/// and <paramref name="serve"/> is skipped. On acquisition the arm body runs and the slot is
/// released in a <c>finally</c>. Combined with <see cref="HistoryReadBatchParallelism"/> this caps
/// total in-flight gateway reads at
/// <c>MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism</c>.
/// </summary>
/// <param name="nodesToProcess">The batch's handles — every slot is failed on limiter saturation.</param>
/// <param name="results">The service-level results list.</param>
/// <param name="errors">The service-level errors list.</param>
/// <param name="serve">The arm body to run once a slot is acquired.</param>
private void WithHistoryReadBatchLimiter(
List<NodeHandle> nodesToProcess,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
Action serve)
{
var gate = GetHistoryReadBatchLimiter();
// Bounded admission wait: cap the block at 5 s, and never exceed the per-request deadline.
var waitMs = HistoryReadDeadline > TimeSpan.Zero
? Math.Min(HistoryReadDeadline.TotalMilliseconds, 5000.0)
: 5000.0;
if (!gate.Wait(TimeSpan.FromMilliseconds(waitMs)))
{
foreach (var handle in nodesToProcess)
{
errors[handle.Index] = StatusCodes.BadTooManyOperations;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
}
return;
}
try
{
serve();
}
finally
{
gate.Release();
}
}
/// <summary>
/// Lazily create (double-checked) the process-wide HistoryRead-batch limiter, sized from
/// <see cref="MaxConcurrentHistoryReadBatches"/> at first use so the Host's post-boot property set is
/// respected (the same benign wiring race as <see cref="MaxTieClusterOverfetch"/>).
/// </summary>
/// <returns>The shared batch limiter semaphore.</returns>
private SemaphoreSlim GetHistoryReadBatchLimiter()
{
var gate = _historyReadBatchLimiter;
if (gate is not null) return gate;
lock (_historyReadBatchLimiterLock)
{
return _historyReadBatchLimiter ??= new SemaphoreSlim(Math.Max(1, MaxConcurrentHistoryReadBatches));
}
}
/// <summary>
/// Create the per-request deadline source for a HistoryRead arm (arch-review 03/S3): a
/// <see cref="CancellationTokenSource"/> that fires after <see cref="HistoryReadDeadline"/>, so a
/// single request can never hold an SDK request thread longer than the deadline regardless of node
/// count. Returns <c>null</c> when the deadline is non-positive (unbounded — the arm then passes
/// <see cref="CancellationToken.None"/>), matching the <c>ServerHistorianOptions</c> warning.
/// </summary>
/// <returns>The deadline source, or <c>null</c> for an unbounded (non-positive) deadline.</returns>
private CancellationTokenSource? CreateDeadline() =>
HistoryReadDeadline > TimeSpan.Zero ? new CancellationTokenSource(HistoryReadDeadline) : null;
/// <summary>Awaits one work item under a semaphore permit, releasing it when the item completes.</summary>
/// <param name="gate">The concurrency-limiting semaphore.</param>
/// <param name="work">The per-node work item.</param>
private static async Task RunGatedAsync(SemaphoreSlim gate, Func<Task> work)
{
await gate.WaitAsync().ConfigureAwait(false);
try
{
await work().ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
private async Task ServeNodeAsync(
NodeHandle handle,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
Func<IHistorianDataSource, string, CancellationToken, Task<HistorianRead>> read,
CancellationToken ct)
{
if (!TryGetHistorizedTagname(handle.NodeId, out var tagname))
{
// Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same
// status, but set it explicitly so the contract is local + obvious.)
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
return;
}
try
{
// HistoryRead is NOT invoked under the node-manager Lock (unlike OnWriteValue), so awaiting
// the async source here is safe and won't freeze the address space.
var sourceResult = await read(HistorianDataSource, tagname!, ct).ConfigureAwait(false);
var historyData = ToHistoryData(sourceResult);
results[handle.Index] = new SdkHistoryReadResult
{
// No source samples ⇒ GoodNoData (the node is historized, the window just held no data).
StatusCode = historyData.DataValues.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyData),
// 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,
};
errors[handle.Index] = ServiceResult.Good;
}
catch (OperationCanceledException)
{
// The per-request deadline elapsed while this node was in flight — surface BadTimeout for
// THIS node only (a spec-conformant history-read status), ahead of the generic catch.
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (Exception ex)
{
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
// Bad status for THIS node only. This CustomNodeManager2 carries no ILogger (see
// ReportConditionEvent), so log through the SDK's static trace rather than swallowing silently.
// Utils.LogError is [Obsolete] in 1.5.378 (favours an ITelemetryContext this manager doesn't
// wire) — suppress the deprecation, matching the existing pattern.
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogError(ex, "OtOpcUaNodeManager: HistoryRead failed for node {0}", handle.NodeId);
#pragma warning restore CS0618
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
}
}
/// <summary>
/// Serve one historized variable handle for a HistoryRead-Raw request WITH server-side
/// continuation-point paging. The single-shot Wonderware backend does not page, so paging is
/// synthesised time-based:
/// <list type="bullet">
/// <item><b>Fresh read</b> (no inbound continuation point): read the window from
/// <c>details.StartTime</c> to <paramref name="endUtc"/> capped at
/// <paramref name="numValuesPerNode"/>. If the page comes back FULL (exactly the cap, and the
/// cap is &gt; 0), store a resume cursor and emit a continuation point.</item>
/// <item><b>Resume read</b> (inbound continuation point present): take the stored cursor, read
/// the next page from the boundary forward, trim already-emitted boundary ties, and emit a
/// FRESH continuation point only if THIS page is also full — else null (done).</item>
/// </list>
/// The resume cursor is tie-safe (see <see cref="HistoryPaging.ComputeResumeCursor"/> /
/// <see cref="HistoryPaging.TrimBoundaryDuplicates"/>): the next page resumes from the boundary
/// timestamp INCLUSIVE and drops the head ties already returned, so samples sharing the boundary
/// SourceTimestamp are neither duplicated nor skipped. Continuation points live in
/// <see cref="HistoryContinuationStore"/> — session-bound + capped in production. Per-node error
/// isolation matches <see cref="ServeNode"/>: a backend throw / an unknown continuation point
/// becomes a Bad status for THIS node only and never throws out of the batch.
/// </summary>
/// <param name="handle">The pre-filtered node handle; <c>handle.Index</c> indexes results/errors.</param>
/// <param name="session">The session the read runs under (null on the session-less in-process path).</param>
/// <param name="nodesToRead">The per-node read list; <c>nodesToRead[handle.Index].ContinuationPoint</c>
/// carries the inbound continuation point (non-null ⇒ a resume read).</param>
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
/// <param name="startTimeUtc">The request window's (inclusive) lower bound, used for a fresh read.</param>
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.</param>
/// <param name="numValuesPerNode">The client's per-page cap; <c>0</c> means "all values, no paging".</param>
private async Task ServeRawPagedAsync(
NodeHandle handle,
ISession? session,
IList<HistoryReadValueId> nodesToRead,
IList<SdkHistoryReadResult> results,
IList<ServiceResult> errors,
DateTime startTimeUtc,
DateTime endUtc,
uint numValuesPerNode,
CancellationToken ct)
{
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
try
{
DateTime startUtc;
var boundarySkip = 0;
string tagname;
if (inboundCp is { Length: > 0 })
{
// Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒
// BadContinuationPointInvalid for THIS node.
var state = _historyContinuationStore.TryTake(session, inboundCp);
if (state is null)
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
return;
}
tagname = state.Tagname;
startUtc = state.NextStartUtc;
boundarySkip = state.BoundarySkipCount;
endUtc = state.EndUtc;
numValuesPerNode = state.NumValuesPerNode;
}
else
{
// Fresh read: resolve the node's historian tagname (as ServeNode does).
if (!TryGetHistorizedTagname(handle.NodeId, out var resolved) || resolved is null)
{
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
return;
}
tagname = resolved;
startUtc = startTimeUtc;
}
// HistoryRead is NOT under the node-manager Lock — awaiting the async source is safe.
var sourceResult = await HistorianDataSource
.ReadRawAsync(tagname, startUtc, endUtc, numValuesPerNode, ct)
.ConfigureAwait(false);
var backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, numValuesPerNode);
// On a resume read, drop the boundary ties already returned on the prior page.
var samples = inboundCp is { Length: > 0 }
? HistoryPaging.TrimBoundaryDuplicates(sourceResult.Samples, startUtc, boundarySkip)
: sourceResult.Samples;
// Oversized tie cluster: a resume read whose FULL backend page does not advance past the resume
// boundary timestamp `startUtc`. That happens when more samples share `startUtc` than the page
// cap — a tie cluster larger than NumValuesPerNode. The fixed-(start,end,cap) backend can only
// ever return the first `cap` of those ties, so the (timestamp, skip) cursor alone can never page
// past (or even reliably within) the cluster: either the boundary-tie trim empties the page, or
// it re-emits the same head ties forever. Detect BOTH stall shapes — an empty trimmed page, or a
// non-empty page whose LAST sample is still at `startUtc` (no forward progress) — and, rather
// than fail the read, over-fetch the WHOLE cluster with an explicit, BOUNDED cap (a start==end
// read at the boundary timestamp) and page WITHIN the timestamp via SliceTieCluster.
var stalledOnTieCluster = inboundCp is { Length: > 0 }
&& backendFull
&& (samples.Count == 0
|| (samples[^1].SourceTimestampUtc ?? DateTime.MinValue) == startUtc);
if (stalledOnTieCluster)
{
// The over-fetch cap MUST be explicit and non-zero: a cap of 0 falls back to the backend's
// MaxValuesPerRead, which would re-introduce the very stall we're escaping. +1 over the bound
// lets us DETECT a cluster strictly larger than the bound (the absurd-burst backstop below).
// Compute in uint so +1 can never wrap: Math.Max(1,…) also guards against a zero/negative
// config value slipping through (Validate() warns but does not block startup).
var overfetchCap = (uint)Math.Max(1, MaxTieClusterOverfetch) + 1u;
var cluster = (await HistorianDataSource
.ReadRawAsync(tagname, startUtc, startUtc, overfetchCap, ct)
.ConfigureAwait(false)).Samples;
// Absurd burst: more ties than we're willing to buffer in memory. Preserve today's loud-fail
// for that node rather than over-fetch an unbounded cluster; the operator's remedy is a
// larger ServerHistorian:MaxTieClusterOverfetch (or NumValuesPerNode).
if (cluster.Count > MaxTieClusterOverfetch)
{
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogError(
"OtOpcUaNodeManager: HistoryReadRaw tie cluster at {0:O} for tag '{1}' has {2} samples, " +
"exceeding MaxTieClusterOverfetch={3}; cannot page within it. Increase MaxTieClusterOverfetch.",
startUtc, tagname, cluster.Count, MaxTieClusterOverfetch);
#pragma warning restore CS0618
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
return;
}
HistoryPaging.SliceTieCluster(
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);
var slice = new List<DataValueSnapshot>(sliceCount);
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
// Emit a continuation point only when SliceTieCluster says the read continues (within the
// cluster, or past it while the window remains). nextSkip is the boundary skip for the next
// page — within the cluster it counts the ties already emitted at startUtc; past it it's 0.
byte[]? clusterCp = null;
if (nextStartUtc is { } resumeAt)
{
var clusterState = new HistoryContinuationState(
tagname, resumeAt, endUtc, nextSkip, numValuesPerNode);
clusterCp = _historyContinuationStore.Save(session, clusterState);
}
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = slice.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples(slice)),
ContinuationPoint = clusterCp,
};
errors[handle.Index] = ServiceResult.Good;
return;
}
// The "full page" test is against the RAW backend count (before trimming): the backend honoured
// the cap, so a full backend page ⇒ there may be more even if we trimmed some boundary ties.
var historyData = ToHistoryDataFromSamples(samples);
byte[]? outboundCp = null;
if (backendFull && samples.Count > 0)
{
HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip);
var nextState = new HistoryContinuationState(
tagname, nextStart, endUtc, skip, numValuesPerNode);
// Save may return null (no session on this request) ⇒ degrade to single-shot for this node.
// Built AFTER historyData so a failure projecting samples can never orphan a stored cursor.
outboundCp = _historyContinuationStore.Save(session, nextState);
}
results[handle.Index] = new SdkHistoryReadResult
{
// No samples ⇒ GoodNoData (the node is historized, the window just held no data). With the
// degenerate-cluster guard above, a resumed empty page now only means the window/cluster is
// genuinely drained — never silent data loss.
StatusCode = samples.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
HistoryData = new ExtensionObject(historyData),
ContinuationPoint = outboundCp,
};
errors[handle.Index] = ServiceResult.Good;
}
catch (OperationCanceledException)
{
// The per-request deadline elapsed while paging this node — BadTimeout for THIS node only.
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (Exception ex)
{
// One node's backend failure must not throw out of the batch — Bad for THIS node only.
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogError(ex, "OtOpcUaNodeManager: HistoryReadRaw (paged) failed for node {0}", handle.NodeId);
#pragma warning restore CS0618
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
}
}
/// <inheritdoc />
protected override void HistoryReleaseContinuationPoints(
ServerSystemContext context,
IList<HistoryReadValueId> nodesToRead,
IList<ServiceResult> errors,
List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
var session = context.OperationContext?.Session;
foreach (var handle in nodesToProcess)
{
var cp = nodesToRead[handle.Index].ContinuationPoint;
if (cp is { Length: > 0 })
{
_historyContinuationStore.Release(session, cp);
}
errors[handle.Index] = ServiceResult.Good;
}
}
/// <summary>Project a plain sample list into an SDK <see cref="HistoryData"/> (the paged Raw path
/// works on a trimmed <see cref="IReadOnlyList{T}"/> rather than a whole <see cref="HistorianRead"/>).</summary>
/// <param name="samples">The samples to project (already trimmed of boundary duplicates).</param>
/// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
private static HistoryData ToHistoryDataFromSamples(IReadOnlyList<DataValueSnapshot> samples)
{
var values = new DataValueCollection(samples.Count);
foreach (var sample in samples) values.Add(ToSdkDataValue(sample));
return new HistoryData { DataValues = values };
}
/// <summary>
/// Map an OPC UA Part 13 standard-aggregate function NodeId to our
/// <see cref="HistoryAggregateType"/>. Returns <c>null</c> for any aggregate we don't serve so
/// the caller can surface <c>BadAggregateNotSupported</c>.
/// </summary>
/// <param name="aggregateNodeId">The per-node aggregate-function NodeId from the request.</param>
/// <returns>The mapped aggregate, or <c>null</c> when unsupported.</returns>
private static HistoryAggregateType? MapAggregate(NodeId aggregateNodeId)
{
if (aggregateNodeId == ObjectIds.AggregateFunction_Average) return HistoryAggregateType.Average;
if (aggregateNodeId == ObjectIds.AggregateFunction_Minimum) return HistoryAggregateType.Minimum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
return null;
}
/// <summary>
/// Project the historian source's <see cref="HistorianRead"/> (Core.Abstractions DTO) into an
/// SDK <see cref="HistoryData"/> — one <see cref="DataValue"/> per <see cref="DataValueSnapshot"/>,
/// carrying value / status / source+server timestamps. A null SourceTimestamp maps to
/// <c>DateTime.MinValue</c> (the SDK's "unset" sentinel for that field).
/// </summary>
/// <param name="sourceResult">The data source's read result.</param>
/// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
private static HistoryData ToHistoryData(HistorianRead sourceResult)
{
var values = new DataValueCollection(sourceResult.Samples.Count);
foreach (var sample in sourceResult.Samples)
{
values.Add(ToSdkDataValue(sample));
}
return new HistoryData { DataValues = values };
}
/// <summary>Convert one driver-agnostic <see cref="DataValueSnapshot"/> to an SDK
/// <see cref="DataValue"/>, mirroring value / status code / source + server timestamps.</summary>
/// <param name="snapshot">The source sample.</param>
/// <returns>The equivalent SDK data value.</returns>
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,
};
/// <inheritdoc />
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{
lock (Lock)
{
base.CreateAddressSpace(externalReferences);
// Create one root folder under Objects/ for every variable we mint to hang under.
_root = new FolderState(null)
{
NodeId = new NodeId("OtOpcUa", NamespaceIndex),
BrowseName = new QualifiedName("OtOpcUa", NamespaceIndex),
DisplayName = "OtOpcUa",
EventNotifier = EventNotifiers.None,
TypeDefinitionId = ObjectTypeIds.FolderType,
};
_root.AddReference(ReferenceTypeIds.Organizes, isInverse: true, ObjectIds.ObjectsFolder);
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var refs))
{
refs = new List<IReference>();
externalReferences[ObjectIds.ObjectsFolder] = refs;
}
refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, isInverse: false, _root.NodeId));
AddPredefinedNode(SystemContext, _root);
}
}
private BaseDataVariableState CreateVariable(string nodeId, ushort ns)
{
var v = new BaseDataVariableState(_root)
{
NodeId = new NodeId(nodeId, ns),
BrowseName = new QualifiedName(nodeId, ns),
DisplayName = nodeId,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = DataTypeIds.BaseDataType,
// Lazy-created nodes (a WriteValue for a node never declared via EnsureVariable) carry no
// array intent, so they stay scalar with no ArrayDimensions — mirrors EnsureVariable's
// scalar branch. Array nodes are always pre-declared via EnsureVariable(isArray:true).
ValueRank = ValueRanks.Scalar,
ArrayDimensions = null,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Historizing = false,
};
_root?.AddChild(v);
AddPredefinedNode(SystemContext, v);
return v;
}
private static StatusCode StatusFromQuality(OpcUaQuality quality) => quality switch
{
OpcUaQuality.Good => StatusCodes.Good,
OpcUaQuality.Uncertain => StatusCodes.Uncertain,
_ => StatusCodes.Bad,
};
}