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; /// /// Custom OPC UA that owns the writable address space for /// the OtOpcUa server. Variable nodes are created lazily on first /// under the manager's namespace; subsequent writes update the existing node's Value + /// StatusCode + SourceTimestamp and notify subscribed clients via the standard /// ClearChangeMasks path. /// /// This is the F10b production wiring behind the v2 /// seam — once a 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. "ns=2;s=eq-1/temp"). Equipment-folder hierarchy /// and OPC UA type metadata still come from the Phase7Applier / EquipmentNodeWalker /// integration (F14b, tracked under #85) — this manager treats every id as a flat /// under the namespace root. /// public sealed class OtOpcUaNodeManager : CustomNodeManager2 { public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns"; private readonly ConcurrentDictionary _variables = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _folders = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _alarmConditions = new(StringComparer.Ordinal); /// Phase C: NodeId → resolved historian tagname for every variable materialised /// Historizing. Populated by when a historian tagname is supplied; the /// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on /// . private readonly ConcurrentDictionary _historizedTagnames = new(StringComparer.Ordinal); /// Folders we have already promoted to event-notifiers + registered as root notifiers, /// so repeated calls don't double-add (idempotent guard). /// Keyed by NodeId → the actual so can /// pass the folder to RemoveRootNotifier on teardown. private readonly Dictionary _notifierFolders = new(); /// Phase C (Task 4): event-notifier folder NodeId-identifier → the event-history source /// name passed to . 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 /// only when a real historian is wired at promotion time, /// and the override resolves an inbound request's notifier NodeId /// against it (a miss ⇒ BadHistoryOperationUnsupported). Cleared on /// . private readonly ConcurrentDictionary _eventNotifierSources = new(StringComparer.Ordinal); private FolderState? _root; /// Initializes a new instance of the class with the OPC UA server and configuration. /// The OPC UA server instance. /// The application configuration. public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration) : base(server, configuration, DefaultNamespaceUri) { // SystemContext is initialised by the base ctor. } /// Gets the count of variable nodes currently managed. public int VariableCount => _variables.Count; /// Gets the count of folder nodes currently managed. public int FolderCount => _folders.Count; /// Gets the count of real Part 9 nodes currently managed. public int AlarmConditionCount => _alarmConditions.Count; /// /// 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 ) gates on the caller's /// AlarmAck role and, when allowed, builds an and invokes this /// delegate. The host sets it at boot to a non-blocking mediator.Tell onto the /// alarm-commands DistributedPubSub topic; T19's engine-side subscriber consumes it. /// /// This is the ONLY reverse coupling out of the node manager — by design it is a plain /// (no Akka / IActorRef / DI handle). The handler /// delegates run under the manager's Lock; the invoked action MUST be non-blocking /// (a fire-and-forget Tell) so there is no deadlock. Null (the default) makes every /// handler a safe no-op — it still gates + returns, just routes nowhere. /// /// public Action? AlarmCommandRouter { get; set; } private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance; /// /// 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 /// handler (, attached by when the /// variable is writable) first gates on the caller's /// role and, when allowed, calls with the node's /// string id + the written value to route the write to the backing driver. /// /// This is the write-side twin of ; the gateway abstraction keeps /// this assembly Akka-free (the host wires an ActorNodeWriteGateway that Asks the local /// DriverHostActor). The handler delegates run under the node-manager Lock (the OPC /// UA SDK's CustomNodeManager2.Write holds Lock while invoking OnWriteValue), /// so the dispatch is FIRE-AND-FORGET — the handler kicks off WriteAsync and returns /// Good immediately so the SDK applies the client value optimistically; it MUST NOT block /// on the device round-trip. When the asynchronous comes back /// FAILED, an off-Lock continuation self-corrects: it re-takes Lock 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 /// / ). /// /// /// Set by the host at StartAsync; the default /// (assigning null restores it) makes every write resolve to a "writes unavailable" /// failure. Backed by a volatile 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. /// /// public IOpcUaNodeWriteGateway NodeWriteGateway { get => _nodeWriteGateway; set => _nodeWriteGateway = value ?? NullOpcUaNodeWriteGateway.Instance; } private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance; /// /// 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 /// Historizing (a tag with 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. /// /// Set by the Host at StartAsync (Task 5). The /// default (assigning null restores it) means "no historian wired" → every read /// returns empty, so a historized node's HistoryRead surfaces GoodNoData rather than /// faulting. Backed by a volatile 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 /// , the HistoryRead override does NOT run under the /// node-manager Lock, so the override may block-bridge to this (async) source. /// /// public IHistorianDataSource HistorianDataSource { get => _historianDataSource; set => _historianDataSource = value ?? NullHistorianDataSource.Instance; } private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore(); /// /// 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 /// binds points to the OPC UA session — so they are /// capped (ServerConfiguration.MaxHistoryContinuationPoints, SDK default 100, oldest-evicted) /// and disposed when the session closes. Exposed (internal) so the session-less in-process tests can /// inject an and exercise the full multi-page round /// trip through the same dispatch path. Assigning null restores the session-backed default. /// internal IHistoryContinuationStore HistoryContinuationStore { get => _historyContinuationStore; set => _historyContinuationStore = value ?? new SessionHistoryContinuationStore(); } /// 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. /// The alarm node identifier (== ScriptedAlarmId). /// The cached , or null when none is registered. public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId) => _alarmConditions.TryGetValue(alarmNodeId, out var condition) ? condition : null; /// 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. /// The variable node identifier. /// The resolved historian tagname when historized; otherwise null. /// True when the node is registered as historized; otherwise false. public bool TryGetHistorizedTagname(string nodeId, out string? tagname) { if (_historizedTagnames.TryGetValue(nodeId, out var t)) { tagname = t; return true; } tagname = null; return false; } /// 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. /// The variable node identifier. /// The cached , or null when none is registered. internal BaseDataVariableState? TryGetVariable(string nodeId) => _variables.TryGetValue(nodeId, out var variable) ? variable : null; /// 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). /// The folder node identifier. /// The cached , or null when none is registered. internal FolderState? TryGetFolder(string nodeId) => _folders.TryGetValue(nodeId, out var folder) ? folder : null; /// /// Apply a value write from . Creates the /// variable node on first call; subsequent calls update Value + StatusCode + /// SourceTimestamp and call ClearChangeMasks so subscribed clients see the change. /// /// The node identifier of the variable. /// The new value to write. /// The OPC UA quality status code. /// The timestamp of the value in UTC. public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { ArgumentException.ThrowIfNullOrEmpty(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(nodeId, out var variable)) { variable = CreateVariable(nodeId); _variables[nodeId] = variable; } variable.Value = value; variable.StatusCode = StatusFromQuality(quality); variable.Timestamp = sourceTimestampUtc; variable.ClearChangeMasks(SystemContext, includeChildren: false); } } /// /// Apply a full Part 9 alarm-condition write. When a real has /// been materialised for (via ), /// this projects the whole snapshot /// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition /// node and recomputes Retain (T15 — richer state; still no event firing, that lands in T16). /// Otherwise it falls back to the legacy two-element [Active, Acknowledged] /// placeholder so callers whose alarm node hasn't been /// materialised (and the existing unit tests) keep working. /// /// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. /// The timestamp of the alarm state change in UTC. public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentNullException.ThrowIfNull(state); // 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(alarmNodeId, 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(alarmNodeId, out var variable)) { variable = CreateVariable(alarmNodeId); _variables[alarmNodeId] = variable; } variable.Value = new[] { state.Active, state.Acknowledged }; variable.StatusCode = StatusCodes.Good; variable.Timestamp = sourceTimestampUtc; variable.ClearChangeMasks(SystemContext, includeChildren: false); } } /// /// Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a /// materialised . The caller MUST already hold Lock and /// have applied the new state via the Set* projection — this stamps a fresh per-event /// EventId, ClearChangeMasks, then ReportEvent with an /// (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). /// /// A fresh EventId 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 /// GetEventByEventId / GetBranch), so T17's ack routing relies on it being unique /// per emission. We use the main branch only (BranchId == NodeId.Null, set at /// materialise) — no branch creation here. /// /// /// Double-emit note (resolved by delta-gate). 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 . The engine then re-projects that same logical /// transition through , which would otherwise fire a second /// event (E3). '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. /// /// /// The materialised condition whose new state has already been projected; must be non-null. /// The source/receive timestamp (UTC) for this event. 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 } } /// /// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a /// condition event AND that an can change. As a record, two /// instances compare by value, so is a plain inequality. /// /// Severity is stored as the MAPPED bucket (a /// ) — the same value the node holds after SetSeverity — so two /// raw severities that fall in the same bucket are correctly treated as "no change". The /// Shelving 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. /// /// /// Why CommentAdded is not a field here (intentional). /// EmissionKind.CommentAdded is produced only by /// Part9StateMachine.ApplyAddComment, which is reached only via /// ScriptedAlarmEngine.AddCommentAsync, which is called only from /// ScriptedAlarmHostActor's inbound AlarmCommand handler — meaning /// CommentAdded ALWAYS originates from a client calling the condition's /// AddComment method. On that path T18's OnAddComment delegate returns /// ServiceResult.Good, 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 . 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 /// CommentAdded would double-emit. There is no engine-internal or script-driven /// comment path, so suppression never drops a needed event. /// /// /// Why Retain is absent (intentional — safe today). /// Retain is projected as state.Active || !state.Acknowledged in /// . Every path that flips Retain necessarily /// changes Active or Acknowledged (both ARE compared fields), so a /// Retain flip always rides along with a real delta and fires correctly. If a /// future engine were to set Retain independently — without touching /// Active/Acknowledged — it would need to be added here. /// /// internal readonly record struct AlarmConditionDelta( bool Active, bool Acknowledged, bool Confirmed, bool Enabled, AlarmShelvingKind Shelving, ushort MappedSeverity, string Message); /// Decide whether a 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 true iff /// any gate-relevant field differs. An inbound client ack the SDK already applied makes /// == false (suppress the re-projected /// double-emit); a genuine engine-driven transition differs ⇒ true (fire). /// The node's current (pre-projection) gate-relevant state. /// The incoming snapshot's gate-relevant state. /// true to fire a condition event; false to suppress (no delta). internal static bool ShouldFireConditionEvent(AlarmConditionDelta current, AlarmConditionDelta incoming) => current != incoming; /// 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 /// bucket the node stores, and shelving is mapped from the shelving /// state machine's CurrentState so it lines up with . 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); /// 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 so it matches /// the bucket the node holds (the projection calls SetSeverity(MapSeverity(...))), 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. 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); /// Map the live shelving state machine's CurrentState back to our 3-way /// 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 /// — the same value folds an /// unsupported shelving snapshot to, so the two stay comparable. 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; } /// /// Materialise a real OPC UA Part 9 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 /// calls — which target that same id — update this node. /// /// This is the T14 production replacement for the bool[2] placeholder: it creates /// node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires /// no events itself. /// /// Idempotent: a second call with the same tears down the prior /// node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected). /// /// The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId. /// The equipment folder node id the condition parents under (null/unknown ⇒ root). /// Human-readable condition name (BrowseName / DisplayName / Message / ConditionName). /// Domain alarm type — maps to the SDK condition subtype (see remarks). /// Domain severity (treated as an OPC UA 1..1000 severity); mapped to . /// /// AlarmType → SDK subtype mapping. Script-driven alarms have no OPC limit / /// setpoint values, so any limit-style subtype would have unset limit children. We therefore /// map: OffNormalAlarm, DiscreteAlarm → /// , and everything else (including AlarmCondition and /// LimitAlarm, which has no script-supplied limits) → the base /// . LimitAlarm deliberately falls back to base per the T13 /// notes — a script alarm carries no High/Low limits to populate. /// public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); lock (Lock) { // 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(alarmNodeId, out var existing)) { existing.Parent?.RemoveChild(existing); PredefinedNodes?.Remove(existing.NodeId); } var parent = ResolveParentFolder(equipmentNodeId); 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, NamespaceIndex), new QualifiedName(displayName, NamespaceIndex), 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. alarm.OnAcknowledge = (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; AlarmCommandRouter?.Invoke(new AlarmCommand(alarmId, "Unshelve", string.Empty, null, null)); return ServiceResult.Good; }; 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[alarmNodeId] = alarm; } } /// /// Shared body for every inbound Part 9 alarm method handler (T18). Resolves the calling /// principal off the SDK , applies the AlarmAck role gate /// (fails closed: a missing identity or a missing role is denied), and on success builds a /// mapped and routes it through . /// /// The SDK context the handler delegate was invoked with — a /// ServerSystemContext (an ) carrying the session /// identity. T17 attached the LDAP roles as a . /// The condition the method targets; its NodeId identifier is the /// ScriptedAlarmId (T14 aligned them), which becomes . /// The Part 9 operation name (e.g. Acknowledge, TimedShelve). /// The call's comment text, or null when none was supplied. /// For TimedShelve, the computed UTC expiry; otherwise null. /// ServiceResult.Good when allowed (the SDK then applies state + auto-fires its /// event); BadUserAccessDenied when the gate vetoes (no route, no state mutation). 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; } /// /// The attached to a writable equipment-tag variable by /// (Task 11). The OPC UA SDK invokes it when a client writes the /// node's Value. It resolves the calling principal off the SDK the /// SAME way does, gates on the /// role + the gateway being wired /// (fails closed: a missing identity / missing role ⇒ BadUserAccessDenied; no gateway ⇒ /// BadNotWritable) via the pure , and on pass dispatches /// the value through . /// /// The dispatch is FIRE-AND-FORGET: the SDK's CustomNodeManager2.Write holds the node /// manager Lock 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 /// lets the SDK apply the written value optimistically. /// /// /// Item A — synchronous structural fail-fast. After the authz gate passes but BEFORE the /// optimistic dispatch, a pure pre-check rejects a /// structurally-invalid write (e.g. a null 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. /// /// /// Write-outcome self-correction. Before returning Good (which makes the SDK overwrite the /// node with ) 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 /// 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 Bad-quality blip (Item B), reverts the node to its /// prior value/status, and raises a Part 8 AuditWriteUpdateEvent (Item C) recording the /// rejected write ( / ). On /// success the optimistic value stands and the next poll re-confirms it via the normal /// path. /// /// 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; var nodeKey = 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, CancellationToken.None) .ContinueWith( t => { var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted"); RevertOptimisticWriteIfNeeded(nodeKey, outcome, optimisticValue, priorValue, priorStatus, clientUserId); }, CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default); return ServiceResult.Good; } /// /// Pure role + availability gate for an inbound equipment-tag write, extracted off /// so it is unit-testable without booting an SDK server. Fails closed: /// a null identity or an identity missing the role ⇒ /// BadUserAccessDenied. When the gate passes but no real gateway is wired /// ( is false) ⇒ BadNotWritable ("writes unavailable"). A /// null return means "proceed" (the caller dispatches + returns Good). Role comparison is /// case-insensitive (the role set is built with ), /// matching the alarm gate. /// /// The role-carrying identity extracted off the SDK context, or null when the /// session is anonymous / carries no role-carrying identity. /// True when a non-Null is wired; false /// for the Null default (no route — e.g. admin-only nodes / pre-boot). /// null to proceed (gate passed); otherwise the veto /// (BadUserAccessDenied on a failed role gate, BadNotWritable when no gateway is wired). 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; } /// /// Item A — synchronous structural fail-fast. Pure structural pre-check for an inbound /// equipment-tag write, run AFTER passes but BEFORE the /// optimistic device dispatch in . A structurally-invalid write is /// rejected INLINE (a Bad 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. /// /// Interpretation / tradeoff (the item was deliberately under-specified). The minimum /// sensible structural check is a null value write to a value variable ⇒ /// BadTypeMismatch (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 /// whose 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 ⇒ BadTypeMismatch. 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 /// /array payload whose element type isn't cheaply known) is allowed through so /// the SDK's own (authoritative) type coercion in BaseVariableState.WriteValue remains the /// final arbiter. We deliberately do NOT attempt deep array-dimension / structured-type validation /// here — that is left to the SDK. /// /// Pure (no SDK server / Lock needed): reads only and the node's static /// DataType, so it is unit-testable in isolation. /// /// The value the client wrote (the SDK's pre-coercion payload). /// The target node (expected to be a writable ). /// null to proceed; otherwise the veto /// (BadTypeMismatch for a null write or a confidently-detected built-in-type mismatch). 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; } /// /// Conservative built-in-type compatibility test for . /// 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 /// array nuance and any /// 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. /// 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; } /// True for the OPC UA numeric built-in types (the integer + floating families). 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; /// The CLOSED set of expected built-in DataTypes the structural fail-fast is allowed to reject /// against — exactly the types 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. private static bool IsCheckableExpectedType(BuiltInType t) => IsNumeric(t) || t is BuiltInType.Boolean or BuiltInType.String or BuiltInType.DateTime or BuiltInType.ByteString; /// /// 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 ) so it is unit-testable without an SDK /// server. /// /// The device-write outcome routed back by the gateway. /// The node's current Value at revert time. /// The value the SDK optimistically applied on the write. /// true to revert (failed outcome and node unchanged since the optimistic write); /// false on success, or when a poll has already moved the node off the optimistic value. internal static bool ShouldRevert(NodeWriteOutcome outcome, object? currentNodeValue, object? optimisticValue) => !outcome.Success && Equals(currentNodeValue, optimisticValue); /// /// Off-Lock continuation body for the write-outcome self-correction: re-takes Lock and, when /// 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 /// ). 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. /// /// /// Item B — Bad-quality blip. Before restoring the prior value, the node is published /// once holding the (still-applied) optimistic value but with StatusCode /// BadDeviceFailure, 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. Caveat: the two ClearChangeMasks 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 /// raised below (Item C) is the reliable, durable record /// of the rejected write. /// /// /// Item C — AuditWriteUpdateEvent. A Part 8 is /// raised through the SDK 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 /// .Reason carried in the event Message). It is reported /// OUTSIDE Lock (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 — ReportEvent simply reaches no monitored /// items, and any failure is swallowed (logged to the SDK trace) so the revert is never broken by it. /// /// /// 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. /// /// The string id of the written variable node. /// The device-write outcome routed back by the gateway. /// The value the SDK optimistically applied on the write. /// The node's real value captured before the optimistic write. /// The node's real status captured before the optimistic write. /// The writing principal's user-id string (the identity's DisplayName), threaded /// from to populate the audit event's ClientUserId; null when unknown. private void RevertOptimisticWriteIfNeeded( string nodeId, NodeWriteOutcome outcome, object? optimisticValue, object? priorValue, StatusCode priorStatus, string? clientUserId) { // Built under Lock if (and only if) a revert is performed, then reported AFTER Lock is released. AuditWriteUpdateEventState? auditEvent = null; lock (Lock) { if (!_variables.TryGetValue(nodeId, 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); } /// /// Item C — build (but do not report) a Part 8 recording a /// rejected device write on . The caller holds Lock (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 helper; this method then fills the write-specific fields /// (AttributeId = Value, IndexRange = empty, OldValue = prior, NewValue = attempted) plus ClientUserId. /// /// The written node (the audit event's SourceNode). /// The failed device-write outcome (its Reason goes into the event Message + Status). /// The value the client attempted (the audit NewValue). /// The node's real pre-write value (the audit OldValue). /// The writing principal's user-id string; null when unknown. /// A populated, unreported . private 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 (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; } /// /// 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 /// ILogger) rather than propagated. Reported with the node manager's SystemContext, which /// is what the alarm event path uses too. /// /// The populated audit event-state to report. 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 } } /// Map our domain AlarmType 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 remarks). 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), }; /// Promote to and /// register it as a root notifier (idempotent — guarded by ) so the /// alarm condition has a notifier path to the Server object for T16's event propagation. /// /// Phase C (Task 4): when a real historian is wired at promotion time (the source is NOT the /// ), the folder ALSO gets the /// bit OR-ed in (keeping SubscribeToEvents) and registers /// its NodeId identifier as an event-history source so the 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 StartAsync — 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 re-promotes it (acceptable, documented). The /// HistoryRead bit + source registration happen inside the same first-time /// block so the idempotency guard covers them too. /// 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); var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty; _eventNotifierSources[sourceName] = sourceName; } AddRootNotifier(folder); folder.ClearChangeMasks(SystemContext, includeChildren: false); } /// Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the /// enum buckets the SDK's SetSeverity expects. private static EventSeverity MapSeverity(int severity) => severity switch { < 200 => EventSeverity.Low, < 400 => EventSeverity.MediumLow, < 600 => EventSeverity.Medium, < 800 => EventSeverity.MediumHigh, _ => EventSeverity.High, }; /// /// Ensure a folder node exists at with the given display /// name, parented under (or the namespace root when null). /// #85 — used by 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. /// /// The node identifier of the folder. /// The node identifier of the parent folder; null to use the namespace root. /// The display name of the folder. public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { ArgumentException.ThrowIfNullOrEmpty(folderNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); if (_folders.ContainsKey(folderNodeId)) return; lock (Lock) { if (_folders.ContainsKey(folderNodeId)) return; var parent = ResolveParentFolder(parentNodeId); var folder = new FolderState(parent) { NodeId = new NodeId(folderNodeId, NamespaceIndex), BrowseName = new QualifiedName(folderNodeId, NamespaceIndex), DisplayName = displayName, EventNotifier = EventNotifiers.None, TypeDefinitionId = ObjectTypeIds.FolderType, ReferenceTypeId = ReferenceTypeIds.Organizes, }; parent.AddChild(folder); AddPredefinedNode(SystemContext, folder); _folders[folderNodeId] = folder; } } /// /// Ensure a Variable node exists at parented under /// (or root when null). Initial value=null, quality=Bad, /// timestamp=epoch — 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 _variables.ContainsKey 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 /// (which the planner triggers on an equipment-tag delta). /// /// The node identifier of the variable. /// The node identifier of the parent folder; null to use the namespace root. /// The display name of the variable. /// The OPC UA data type name (e.g., "Boolean", "Int32", "String"). /// When true the node is created CurrentReadWrite (an authored /// ReadWrite equipment tag) and the inbound-write handler is attached /// to its OnWriteValue (Task 11) so a client write gates on the WriteOperate role + routes /// to the backing driver; when false it stays CurrentRead (read-only) with no write handler. /// Phase C: null ⇒ the node is NOT historized (Historizing=false, no /// HistoryRead bit, not registered). Non-null ⇒ the node is created Historizing with the /// HistoryRead access bit OR-ed into both AccessLevel and UserAccessLevel, and the /// (already default-resolved) tagname is registered in the NodeId→tagname map the HistoryRead override /// resolves against. public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) { ArgumentException.ThrowIfNullOrEmpty(variableNodeId); ArgumentException.ThrowIfNullOrEmpty(displayName); // If already present, leave it alone (idempotent re-applies). if (_variables.ContainsKey(variableNodeId)) return; lock (Lock) { if (_variables.ContainsKey(variableNodeId)) return; var parent = ResolveParentFolder(parentFolderNodeId); // 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. OR-ing two byte constants promotes to int, so cast back. byte access = writable ? (byte)(AccessLevels.CurrentRead | AccessLevels.CurrentWrite) : AccessLevels.CurrentRead; if (historized) access = (byte)(access | AccessLevels.HistoryRead); var variable = new BaseDataVariableState(parent) { NodeId = new NodeId(variableNodeId, NamespaceIndex), BrowseName = new QualifiedName(variableNodeId, NamespaceIndex), DisplayName = displayName, TypeDefinitionId = VariableTypeIds.BaseDataVariableType, ReferenceTypeId = ReferenceTypeIds.Organizes, DataType = ResolveBuiltInDataType(dataType), ValueRank = ValueRanks.Scalar, AccessLevel = access, UserAccessLevel = access, Historizing = historized, Value = null, StatusCode = StatusCodes.BadWaitingForInitialData, Timestamp = DateTime.MinValue, }; // Task 11: 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[variableNodeId] = variable; // Phase C: register the resolved historian tagname so the HistoryRead override can map this // NodeId back to its Aveva/historian source. if (historized) _historizedTagnames[variableNodeId] = historianTagname!; } } /// 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). 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, }; /// Clear every registered variable + folder from the address space. Phase7Applier /// calls this when Equipment/Alarm topology changes; the populator then re-adds via /// EnsureFolder + WriteValue on the next pass. 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); } _alarmConditions.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 (Task 4): 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(); } } private FolderState ResolveParentFolder(string? parentNodeId) { if (string.IsNullOrEmpty(parentNodeId)) return _root!; return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!; } // --------------------------------------------------------------------------------------------- // 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, Task 4). 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. // --------------------------------------------------------------------------------------------- /// /// Serve a HistoryRead-Raw request over the pre-filtered historized variable handles, dispatching /// each to . Modified-history reads /// (IsReadModified) are unsupported — we don't serve a modified-value history surface. /// /// Raw is the only arm that pages server-side: ReadRawModifiedDetails carries a client /// count cap (NumValuesPerNode), so a page that returns exactly that many samples MAY /// have more behind it ⇒ a time-based continuation point is emitted (see /// ). An inbound continuation point on a node resumes its stored /// read. NumValuesPerNode == 0 ("all values") never pages. /// /// protected override void HistoryReadRawModified( ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestampsToReturn, IList nodesToRead, IList results, IList errors, List nodesToProcess, IDictionary cache) { var session = context.OperationContext?.Session; 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; } ServeRawPaged( handle, session, nodesToRead, results, errors, details.StartTime, details.EndTime, details.NumValuesPerNode); } } /// /// Serve a HistoryRead-Processed request, mapping each node's per-node aggregate NodeId (from the /// parallel AggregateType collection — the base guarantees it is the same length as /// nodesToRead) to a and dispatching to /// . An unknown aggregate yields /// BadAggregateNotSupported for that node. Single-shot (no continuation point): /// ReadProcessedDetails carries no client count cap — the bucket count is deterministic /// (window / interval) — so there is no "full page" signal to page on. /// protected override void HistoryReadProcessed( ServerSystemContext context, ReadProcessedDetails details, TimestampsToReturn timestampsToReturn, IList nodesToRead, IList results, IList errors, List nodesToProcess, IDictionary cache) { // OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch. var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval); 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). ServeNode(handle, results, errors, (source, tagname) => source.ReadProcessedAsync( tagname, details.StartTime, details.EndTime, interval, aggregate.Value, CancellationToken.None)); } } /// /// Serve a HistoryRead-AtTime request, dispatching the requested timestamps to /// . Single-shot (no continuation point): /// AtTime carries no client count cap — the request IS the timestamp list and the result is /// exactly one sample per requested timestamp — so there is no "full page" signal to page on. /// protected override void HistoryReadAtTime( ServerSystemContext context, ReadAtTimeDetails details, TimestampsToReturn timestampsToReturn, IList nodesToRead, IList results, IList errors, List nodesToProcess, IDictionary cache) { // Snapshot the requested timestamps once — the same list is read for every node. var timestamps = details.ReqTimes?.ToList() ?? new List(); foreach (var handle in nodesToProcess) { ServeNode(handle, results, errors, (source, tagname) => source.ReadAtTimeAsync( tagname, timestamps, CancellationToken.None)); } } /// /// Serve a HistoryRead-Events request over the equipment-folder event-notifier nodes (the folders /// that own alarm conditions). Each handle's NodeId identifier is resolved against /// : a miss ⇒ BadHistoryOperationUnsupported (a node we own /// that isn't a registered event-history source — e.g. a plain folder, or one promoted while no /// historian was wired); a hit block-bridges to /// for the folder's source name and projects each into a /// per the request's event filter. Like the Raw/Processed/AtTime /// arms this is NOT invoked under the node-manager Lock, so the block-bridge is safe; each /// handle is served under try/catch so a backend throw becomes a Bad status for THAT node only and /// never throws out of the batch. /// protected override void HistoryReadEvents( ServerSystemContext context, ReadEventDetails details, TimestampsToReturn timestampsToReturn, IList nodesToRead, IList results, IList errors, List nodesToProcess, IDictionary cache) { // Snapshot the select clauses once — the same filter projects every node's events. var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection(); foreach (var handle in nodesToProcess) { var idString = handle.NodeId.Identifier?.ToString(); if (idString is null || !_eventNotifierSources.TryGetValue(idString, 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; } try { // NOT under the node-manager Lock — block-bridging the async source is safe here. var sourceResult = _historianDataSource.ReadEventsAsync( sourceName, details.StartTime, details.EndTime, // NumValuesPerNode is uint; ReadEventsAsync takes int (<=0 ⇒ backend default cap). ClampToInt(details.NumValuesPerNode), CancellationToken.None).GetAwaiter().GetResult(); 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 (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 ServeNode). #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 }; } } } /// Clamp a request cap to a non-negative for the /// event-read surface (whose maxEvents is signed): values above /// saturate to . /// The uint cap from the request (NumValuesPerNode). /// The clamped non-negative int. private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value; /// /// Project a sequence of s into an SDK — /// one per event, each carrying the requested /// ' fields in select-clause order (see /// ). /// /// The historian's event rows. /// The request's event-filter select clauses (the fields to emit, in order). /// The populated SDK . private static HistoryEvent ProjectEvents( IReadOnlyList 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 }; } /// /// Project one field requested by a select /// into a . Mapping is by the operand's BrowsePath LEAF QualifiedName name /// (case-sensitive, per the OPC UA BaseEventType field names) against the /// shape: /// EventId→ByteString, SourceName→String, Time→DateTime, /// ReceiveTime→DateTime, Message→LocalizedText, Severity→UInt16. Any other /// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒ /// — spec-conformant: a field the server can't supply is null. /// /// The source event row. /// The select-clause operand naming the field to project. /// The projected variant, or for an unsupported field. 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, }; } /// Extract the leaf (last) from a select operand's BrowsePath, /// or null when the BrowsePath is null/empty. /// The select-clause operand. /// The leaf field name, or null for an empty BrowsePath. private static string? LeafFieldName(SimpleAttributeOperand operand) { var path = operand.BrowsePath; if (path is null || path.Count == 0) return null; return path[^1].Name; } /// /// 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 lookup; the resolved tagname is passed directly /// to , removing any risk of a second concurrent lookup on the same key. /// A node we don't recognise as historized maps to BadHistoryOperationUnsupported /// (shouldn't normally reach us, since the base only hands us nodes with the HistoryRead access /// bit, but we guard explicitly). The 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. /// /// The pre-filtered node handle to serve; handle.Index indexes results/errors. /// The service-level results list to fill at handle.Index. /// The service-level errors list to fill at handle.Index. /// Invokes the resolved data-source read with the resolved tagname; only called /// once the tagname is confirmed present. private void ServeNode( NodeHandle handle, IList results, IList errors, Func> read) { var idString = handle.NodeId.Identifier?.ToString(); if (idString is null || !TryGetHistorizedTagname(idString, 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 blocking // on the async source here is safe and won't freeze the address space. var sourceResult = read(HistorianDataSource, tagname!).GetAwaiter().GetResult(); 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 (Exception ex) { // One node's backend failure (throw / timeout / cancellation) 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; } } /// /// 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: /// /// Fresh read (no inbound continuation point): read the window from /// details.StartTime to capped at /// . If the page comes back FULL (exactly the cap, and the /// cap is > 0), store a resume cursor and emit a continuation point. /// Resume read (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). /// /// The resume cursor is tie-safe (see / /// ): 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 /// — session-bound + capped in production. Per-node error /// isolation matches : a backend throw / an unknown continuation point /// becomes a Bad status for THIS node only and never throws out of the batch. /// /// The pre-filtered node handle; handle.Index indexes results/errors. /// The session the read runs under (null on the session-less in-process path). /// The per-node read list; nodesToRead[handle.Index].ContinuationPoint /// carries the inbound continuation point (non-null ⇒ a resume read). /// The service-level results list to fill at handle.Index. /// The service-level errors list to fill at handle.Index. /// The request window's (inclusive) lower bound, used for a fresh read. /// The (inclusive) upper bound of the read window; unchanged across pages. /// The client's per-page cap; 0 means "all values, no paging". private void ServeRawPaged( NodeHandle handle, ISession? session, IList nodesToRead, IList results, IList errors, DateTime startTimeUtc, DateTime endUtc, uint numValuesPerNode) { 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). var idString = handle.NodeId.Identifier?.ToString(); if (idString is null || !TryGetHistorizedTagname(idString, out var resolved) || resolved is null) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; return; } tagname = resolved; startUtc = startTimeUtc; } // HistoryRead is NOT under the node-manager Lock — block-bridging the async source is safe. var sourceResult = HistorianDataSource .ReadRawAsync(tagname, startUtc, endUtc, numValuesPerNode, CancellationToken.None) .GetAwaiter().GetResult(); 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; // Degenerate tie cluster: a resume read returned a FULL backend page that the boundary-tie trim // emptied entirely. That can only happen when more than NumValuesPerNode samples share the resume // boundary timestamp — a tie cluster larger than the page cap. The fixed-(start,end,cap) backend // can only ever return the first `cap` of those ties, so a (timestamp, skip) cursor can never // advance past the cluster. Fail LOUDLY for this node rather than silently truncate to GoodNoData // (which would permanently drop the un-emitted ties). The operator's remedy is a larger // NumValuesPerNode; see docs/Historian.md "Paging limitation". if (inboundCp is { Length: > 0 } && backendFull && samples.Count == 0) { #pragma warning disable CS0618 // Type or member is obsolete Utils.LogError( "OtOpcUaNodeManager: HistoryReadRaw paging stalled — tie cluster at {0:O} for tag '{1}' " + "exceeds NumValuesPerNode={2}; cannot page past it. Increase NumValuesPerNode.", startUtc, tagname, numValuesPerNode); #pragma warning restore CS0618 errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported }; 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 (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; } } /// /// Drop the resume state for any continuation points the client asked to release /// (releaseContinuationPoints == true) and return WITHOUT reading data, per OPC UA Part 4. /// The base dispatcher routes a release-only HistoryRead here (it never reaches the per-details /// arms), so this is the single place that must free Raw's stored cursors. Each handle's released /// point is nodesToRead[handle.Index].ContinuationPoint; releasing an unknown / null point /// is a harmless no-op. Errors are left Good (the base pre-seeds them) — a release does not fail. /// protected override void HistoryReleaseContinuationPoints( ServerSystemContext context, IList nodesToRead, IList errors, List nodesToProcess, IDictionary 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; } } /// Project a plain sample list into an SDK (the paged Raw path /// works on a trimmed rather than a whole ). /// The samples to project (already trimmed of boundary duplicates). /// The populated SDK . private static HistoryData ToHistoryDataFromSamples(IReadOnlyList samples) { var values = new DataValueCollection(samples.Count); foreach (var sample in samples) values.Add(ToSdkDataValue(sample)); return new HistoryData { DataValues = values }; } /// /// Map an OPC UA Part 13 standard-aggregate function NodeId to our /// . Returns null for any aggregate we don't serve so /// the caller can surface BadAggregateNotSupported. /// /// The per-node aggregate-function NodeId from the request. /// The mapped aggregate, or null when unsupported. 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; } /// /// Project the historian source's (Core.Abstractions DTO) into an /// SDK — one per , /// carrying value / status / source+server timestamps. A null SourceTimestamp maps to /// DateTime.MinValue (the SDK's "unset" sentinel for that field). /// /// The data source's read result. /// The populated SDK . 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 }; } /// Convert one driver-agnostic to an SDK /// , mirroring value / status code / source + server timestamps. /// The source sample. /// The equivalent SDK data value. private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new() { WrappedValue = new Variant(snapshot.Value), StatusCode = new StatusCode(snapshot.StatusCode), SourceTimestamp = snapshot.SourceTimestampUtc ?? DateTime.MinValue, ServerTimestamp = snapshot.ServerTimestampUtc, }; /// public override void CreateAddressSpace(IDictionary> 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(); externalReferences[ObjectIds.ObjectsFolder] = refs; } refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, isInverse: false, _root.NodeId)); AddPredefinedNode(SystemContext, _root); } } private BaseDataVariableState CreateVariable(string nodeId) { var v = new BaseDataVariableState(_root) { NodeId = new NodeId(nodeId, NamespaceIndex), BrowseName = new QualifiedName(nodeId, NamespaceIndex), DisplayName = nodeId, TypeDefinitionId = VariableTypeIds.BaseDataVariableType, ReferenceTypeId = ReferenceTypeIds.Organizes, DataType = DataTypeIds.BaseDataType, ValueRank = ValueRanks.Scalar, 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, }; }