From 60d48a2a0a498096d5a2d2db16790e4c894ab2d5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 19:19:10 -0400 Subject: [PATCH] feat(scripted-alarms): materialise real Part 9 AlarmConditionState nodes (T14) --- docs/v2/f14b-part9-sdk-notes.md | 19 +- .../OpcUa/DeferredAddressSpaceSink.cs | 9 + .../OpcUa/IOpcUaAddressSpaceSink.cs | 23 ++- .../OtOpcUaNodeManager.cs | 181 +++++++++++++++++- .../Phase7Applier.cs | 36 ++++ .../SdkAddressSpaceSink.cs | 9 + .../OpcUa/OpcUaPublishActor.cs | 5 + .../DeferredAddressSpaceSinkTests.cs | 3 + .../Phase7ApplierHierarchyTests.cs | 7 + .../Phase7ApplierTests.cs | 51 +++++ .../SdkAddressSpaceSinkTests.cs | 89 +++++++++ .../OtOpcUaTelemetryHookTests.cs | 7 + .../OpcUa/OpcUaPublishActorRebuildTests.cs | 8 + .../OpcUa/OpcUaPublishActorTests.cs | 8 + 14 files changed, 443 insertions(+), 12 deletions(-) diff --git a/docs/v2/f14b-part9-sdk-notes.md b/docs/v2/f14b-part9-sdk-notes.md index 1f843b35..6afd7872 100644 --- a/docs/v2/f14b-part9-sdk-notes.md +++ b/docs/v2/f14b-part9-sdk-notes.md @@ -581,11 +581,20 @@ ServiceResult OnAck(ISystemContext ctx, ConditionState c, byte[] eventId, Locali confirm `Server.Telemetry` (`ITelemetryContext`) is non-null in our host before relying on the telemetry ctor — fall back to `new AlarmConditionState(parent)` if not. (Not load-bearing; pick whichever the host supports.) -2. **Optional children before `Create`.** Whether `ShelvingState` / - `ConfirmedState` are auto-created by `Create` or must be instantiated first - (the sample instantiates them) — **[SAMPLE-ONLY]** behaviour; verify by - inspecting the live node after `Create` (browse the children). If Confirm / - Shelve children are missing, materialise them like the sample before `Create`. +2. **Optional children before `Create`.** ~~Whether `ShelvingState` / + `ConfirmedState` are auto-created by `Create` or must be instantiated first.~~ + **RESOLVED in T14 (real-server integration test, 1.5.378.106):** `Create` + auto-builds the **full** optional Part 9 child set from the embedded type + definition with **no** pre-setting — for `OffNormalAlarmState`, both + `ConfirmedState` AND `ShelvingState` come back **non-null** after `Create` + (richer than the `[SAMPLE-ONLY]` caveat predicted). So T15/T16 can call + `SetConfirmedState` / `SetShelvingState` directly; no manual child + materialisation is needed. **Gotcha also found:** `BranchId.Value` is left a + **null reference** by `Create`, and the very first `Set*` call + (`SetEnableState` → `UpdateRetainState` → `GetRetainState` → `IsBranch()`) + **NREs** on it. Fix: set `alarm.BranchId.Value = NodeId.Null` (the main + branch) **before** any `Set*` call. T14's `MaterialiseAlarmCondition` does + this. (Covered by `SdkAddressSpaceSinkTests.MaterialiseAlarmCondition_*`.) 3. **`InstanceStateSnapshot` vs reporting the node directly.** The sample uses an `InstanceStateSnapshot` as the `IFilterTarget`. Confirm whether reporting the alarm node itself (which is also an `IFilterTarget`) is acceptable — the diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs index 3bb7e934..d652d407 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -38,6 +38,15 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) => _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc); + /// Materialises a real Part 9 alarm-condition node through the inner sink. + /// The alarm node ID (== ScriptedAlarmId). + /// The equipment folder node ID the condition parents under. + /// The human-readable condition name. + /// The domain alarm type. + /// The domain severity. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) + => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity); + /// Ensures a folder exists in the address space through the inner sink. /// The node ID of the folder. /// The node ID of the parent folder, or null for root. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs index eb7b7c3a..7708b0be 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -15,13 +15,29 @@ public interface IOpcUaAddressSpaceSink /// The source timestamp in UTC. void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc); - /// Write an alarm-condition Variable's active/acknowledged state. - /// The OPC UA node ID of the alarm. + /// Write an alarm-condition's active/acknowledged state. When a real Part 9 condition + /// node has been materialised for via + /// , this projects onto its ActiveState/AckedState/Retain; + /// otherwise it falls back to the legacy two-element placeholder variable. + /// The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions). /// Whether the alarm is active. /// Whether the alarm has been acknowledged. /// The source timestamp in UTC. void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc); + /// + /// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients + /// can browse it as a proper condition (with basic Active/Ack state). The node id equals the + /// alarm node id (the ScriptedAlarmId) so subsequent calls update + /// it. Used by Phase7Applier.MaterialiseScriptedAlarms. Idempotent. + /// + /// The alarm node ID (== ScriptedAlarmId); becomes the condition's NodeId. + /// The equipment folder node ID the condition parents under. + /// Human-readable condition name (BrowseName / DisplayName / Message). + /// Domain alarm type — mapped to the SDK condition subtype by the sink. + /// Domain severity (OPC UA 1..1000 scale); mapped to the SDK severity buckets. + void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity); + /// /// Ensure a folder node exists under the given parent. Used by Phase7Applier to /// materialise the UNS Area/Line/Equipment hierarchy in the address space. When @@ -70,6 +86,9 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink /// public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { } + /// + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { } + /// public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index adbc7873..56a47fa2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -28,6 +28,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private readonly ConcurrentDictionary _variables = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _folders = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _alarmConditions = new(StringComparer.Ordinal); + /// Folders we have already promoted to event-notifiers + registered as root notifiers, + /// so repeated calls don't double-add (idempotent guard). + private readonly HashSet _notifierFolders = new(); private FolderState? _root; /// Initializes a new instance of the class with the OPC UA server and configuration. @@ -43,6 +47,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 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; + + /// 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; /// /// Apply a value write from . Creates the @@ -67,18 +80,44 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - /// Apply an alarm-state write. Surfaced as a two-element Variable carrying - /// [active, acknowledged] — proper AlarmConditionState + event firing - /// comes when the F14b walker integration lands and registers real condition nodes. - /// The node identifier of the alarm variable. + /// + /// Apply an alarm-state write. When a real Part 9 has been + /// materialised for (via ), + /// this projects / onto the live + /// condition node's ActiveState/AckedState/Retain (T14 — basic active/ack only; no event + /// firing yet, 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). /// Whether the alarm is currently active. /// Whether the alarm has been acknowledged. /// The timestamp of the alarm state change in UTC. public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { ArgumentException.ThrowIfNullOrEmpty(alarmNodeId); - var variable = _variables.GetOrAdd(alarmNodeId, CreateVariable); + if (_alarmConditions.TryGetValue(alarmNodeId, out var condition)) + { + lock (Lock) + { + condition.SetActiveState(SystemContext, active); + condition.SetAcknowledgedState(SystemContext, acknowledged); + // Part 9: retain the condition while it is active OR unacknowledged so a client's + // ConditionRefresh replays it. T16's event firing will also drive Retain; here we keep + // it correct for the basic projection. + condition.Retain.Value = active || !acknowledged; + condition.Time.Value = sourceTimestampUtc; + condition.ReceiveTime.Value = sourceTimestampUtc; + // NO ReportEvent here — T16 owns event firing. ClearChangeMasks just notifies any + // attribute (not event) subscribers watching ActiveState/AckedState/Retain directly. + condition.ClearChangeMasks(SystemContext, includeChildren: true); + } + return; + } + + // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable. + var variable = _variables.GetOrAdd(alarmNodeId, CreateVariable); lock (Lock) { variable.Value = new[] { active, acknowledged }; @@ -88,6 +127,127 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + /// + /// Materialise a real OPC UA Part 9 node under its equipment + /// folder so clients can browse it as a proper condition (and, once T16 lands, 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; + + 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; + } + } + + /// 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. + private void EnsureFolderIsEventNotifier(FolderState folder) + { + if (!_notifierFolders.Add(folder.NodeId)) return; + folder.EventNotifier = EventNotifiers.SubscribeToEvents; + 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 + { + <= 0 => EventSeverity.Low, + < 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). @@ -206,12 +366,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } _variables.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(); + + // Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt) + // equipment folders to event notifiers. + _notifierFolders.Clear(); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs index fa36c740..c0f7d032 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs @@ -285,6 +285,36 @@ public sealed class Phase7Applier composition.EquipmentVirtualTags.Select(v => v.EquipmentId).Distinct(StringComparer.Ordinal).Count()); } + /// + /// T14 — materialise real OPC UA Part 9 AlarmConditionState nodes from a composition + /// snapshot. For each enabled , register a + /// condition node (keyed by its , which + /// is the same id OpcUaPublishActor.AlarmStateUpdate targets) under its equipment folder. + /// Disabled alarms are skipped — they expose no node. Must run AFTER + /// so the equipment folders exist. Idempotent (the sink's + /// MaterialiseAlarmCondition re-creates cleanly on re-apply). + /// + /// The composition result containing the scripted alarms to materialise. + public void MaterialiseScriptedAlarms(Phase7CompositionResult composition) + { + ArgumentNullException.ThrowIfNull(composition); + if (composition.EquipmentScriptedAlarms.Count == 0) return; + + var materialised = 0; + foreach (var alarm in composition.EquipmentScriptedAlarms) + { + if (!alarm.Enabled) continue; + SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity); + materialised++; + } + + _logger.LogInformation( + "Phase7Applier: scripted alarms materialised (alarms={Alarms}, equipment={Equipment})", + materialised, + composition.EquipmentScriptedAlarms.Where(a => a.Enabled) + .Select(a => a.EquipmentId).Distinct(StringComparer.Ordinal).Count()); + } + /// Deterministic NodeId for a tag's FolderPath sub-folder, scoped under its equipment /// folder so two equipments' identically-named sub-folders never collide. private static string EquipmentSubFolderNodeId(string equipmentId, string folderPath) => @@ -307,6 +337,12 @@ public sealed class Phase7Applier try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); } catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: WriteAlarmState threw for {Node}", nodeId); } } + + private void SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) + { + try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity); } + catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); } + } } /// Summary of one apply pass. Useful for tests + audit-log entries on the deploy path. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs index 69fb2135..31367660 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs @@ -36,6 +36,15 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) => _nodeManager.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc); + /// Materialises a real Part 9 alarm-condition node in the address space. + /// The alarm node identifier (== ScriptedAlarmId). + /// The equipment folder node identifier the condition parents under. + /// The human-readable condition name. + /// The domain alarm type. + /// The domain severity. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) + => _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity); + /// Ensures a folder node exists in the address space. /// The folder node identifier. /// The parent folder node identifier. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 81464a11..983f7977 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -229,6 +229,11 @@ public sealed class OpcUaPublishActor : ReceiveActor // clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier // skips folders that already exist with the same node id. _applier.MaterialiseHierarchy(composition); + // T14 — scripted alarms get their own pass right after the hierarchy so the equipment + // folders they parent under already exist. Materialises real Part 9 AlarmConditionState + // nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled + // alarms are skipped. + _applier.MaterialiseScriptedAlarms(composition); // Galaxy / SystemPlatform tags get their own pass: ensures their FolderPath folder // + Variable node exist so clients can browse them. The Galaxy driver fills values // on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs index 1961e3c8..2011d5c8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs @@ -81,6 +81,9 @@ public sealed class DeferredAddressSpaceSinkTests public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) => CallQueue.Enqueue($"WA:{alarmNodeId}"); /// + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) + => CallQueue.Enqueue($"MA:{alarmNodeId}"); + /// public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) => CallQueue.Enqueue($"EF:{folderNodeId}"); /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs index ae8610fe..baa2717a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs @@ -252,6 +252,13 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable /// Whether the alarm has been acknowledged. /// The source timestamp in UTC. public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { } + /// Materialises an alarm condition (stub implementation for testing). + /// The alarm node ID (== ScriptedAlarmId). + /// The equipment folder node ID. + /// The condition display name. + /// The domain alarm type. + /// The domain severity. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { } /// Records a folder creation request. /// The node ID of the folder. /// The node ID of the parent folder, or null for root. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs index 7e09f541..8af8ad9c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs @@ -311,6 +311,38 @@ public sealed class Phase7ApplierTests sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64")); } + /// T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by + /// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS + /// disabled alarms. + [Fact] + public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled() + { + var sink = new RecordingSink(); + var applier = new Phase7Applier(sink, NullLogger.Instance); + + var composition = new Phase7CompositionResult( + Array.Empty(), Array.Empty(), Array.Empty()) + { + EquipmentScriptedAlarms = new[] + { + new EquipmentScriptedAlarmPlan( + ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm", + Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;", + DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: true), + new EquipmentScriptedAlarmPlan( + ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition", + Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;", + DependencyRefs: Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: false), + }, + }; + + applier.MaterialiseScriptedAlarms(composition); + + // Only the enabled alarm is materialised; the disabled one is skipped entirely. + sink.AlarmConditionCalls.ShouldHaveSingleItem() + .ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700)); + } + /// Verifies that added equipment tags in an otherwise-empty plan trigger an /// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment /// tags, so a tags-only deploy is no longer a silent no-op). @@ -419,6 +451,8 @@ public sealed class Phase7ApplierTests public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new(); /// Gets the queue of variable creation calls. public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new(); + /// Gets the queue of alarm-condition materialise calls. + public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new(); /// Gets the number of rebuild calls made on this sink. public int RebuildCalls; @@ -428,6 +462,8 @@ public sealed class Phase7ApplierTests public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList(); /// Gets the list of recorded variable creation calls. public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList(); + /// Gets the list of recorded alarm-condition materialise calls. + public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList(); /// Records a value write (no-op in this recording sink). /// The node ID. @@ -442,6 +478,14 @@ public sealed class Phase7ApplierTests /// The source timestamp in UTC. public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) => AlarmQueue.Enqueue((alarmNodeId, active, acknowledged)); + /// Records an alarm-condition materialise call. + /// The alarm node ID (== ScriptedAlarmId). + /// The equipment folder node ID. + /// The condition display name. + /// The domain alarm type. + /// The domain severity. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) + => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity)); /// Records a folder creation call. /// The folder node ID. /// The parent folder node ID, if any. @@ -482,6 +526,13 @@ public sealed class Phase7ApplierTests { if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault"); } + /// No-op alarm-condition materialise call. + /// The alarm node ID. + /// The equipment folder node ID. + /// The condition display name. + /// The domain alarm type. + /// The domain severity. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { } /// No-op folder creation call. /// The folder node ID. /// The parent folder node ID, if any. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 18c37d0b..1f45463d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; @@ -85,6 +86,94 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable await Task.CompletedTask; } + /// T14 — materialises an equipment folder + a real Part 9 AlarmConditionState under it, + /// then projects active state through WriteAlarmState. Asserts the node is a real + /// , reachable under the equipment folder, and that + /// ActiveState/Retain reflect the write. Also inspects which optional Part 9 children + /// Create auto-builds (the T13 uncertainty) and records the finding inline. + [Fact] + public async Task MaterialiseAlarmCondition_creates_real_condition_node_and_WriteAlarmState_updates_it() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var sink = new SdkAddressSpaceSink(nm); + + // Equipment folder must exist first (MaterialiseHierarchy owns this in production). + sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1"); + + // Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmState targets it. + sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); + + nm.AlarmConditionCount.ShouldBe(1); + + var condition = nm.TryGetAlarmCondition("alm-1"); + condition.ShouldNotBeNull(); + // It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm"). + condition.ShouldBeOfType(); + condition.NodeId.ShouldBe(new NodeId("alm-1", nm.NamespaceIndex)); + + // Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child). + condition.Parent.ShouldNotBeNull(); + condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", nm.NamespaceIndex)); + + // Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false. + condition.EnabledState.Id.Value.ShouldBeTrue(); + condition.ActiveState.Id.Value.ShouldBeFalse(); + condition.Retain.Value.ShouldBeFalse(); + + // --- T13 optional-children finding (RESOLVED by THIS real-server test) --- + // AckedState is mandatory on AcknowledgeableConditionState so it is always present. + condition.AckedState.ShouldNotBeNull(); + // FINDING (1.5.378.106): Create auto-builds the FULL optional Part 9 child set from the + // embedded type definition WITHOUT us pre-setting any property — both ConfirmedState (Confirm + // sub-state machine) AND ShelvingState (Shelve state machine) come back non-null. This is + // RICHER than the SDK-notes' [SAMPLE-ONLY] caveat predicted (it suggested we'd have to + // instantiate optional children ourselves). Net: T15/T16 can drive SetConfirmedState / + // SetShelvingState directly — no manual child materialisation needed. Asserting both non-null + // so a future SDK bump that changes auto-build behaviour fails loudly. + condition.ConfirmedState.ShouldNotBeNull(); + condition.ShelvingState.ShouldNotBeNull(); + + // WriteAlarmState now targets the real condition (not the bool[2] placeholder): no extra + // BaseDataVariable is minted for the alarm id. + sink.WriteAlarmState("alm-1", active: true, acknowledged: false, DateTime.UtcNow); + nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken + + condition.ActiveState.Id.Value.ShouldBeTrue(); + condition.AckedState.Id.Value.ShouldBeFalse(); + condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain + + // Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id. + sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700); + nm.AlarmConditionCount.ShouldBe(1); + + // RebuildAddressSpace clears the alarm dict too. + sink.RebuildAddressSpace(); + nm.AlarmConditionCount.ShouldBe(0); + + await host.DisposeAsync(); + } + + /// An unknown / limit-style AlarmType (with no script-supplied OPC limits) falls back to + /// the base per the T13 notes. + [Fact] + public async Task MaterialiseAlarmCondition_unknown_type_falls_back_to_base_condition() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var sink = new SdkAddressSpaceSink(nm); + + sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9"); + sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500); + + var condition = nm.TryGetAlarmCondition("alm-x"); + condition.ShouldNotBeNull(); + // Base type exactly — NOT a LimitAlarmState (no limits to populate for a script alarm). + condition.GetType().ShouldBe(typeof(AlarmConditionState)); + + await host.DisposeAsync(); + } + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var host = new OpcUaApplicationHost( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs index 62e08ee6..3dee5c97 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs @@ -201,6 +201,13 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase /// Whether the alarm is acknowledged. /// The time the alarm occurred in UTC. public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime occurredUtc) => Writes++; + /// Materialises an alarm condition (stub implementation). + /// The alarm node identifier. + /// The equipment folder node identifier. + /// The condition display name. + /// The domain alarm type. + /// The domain severity. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { } /// Ensures folder exists (stub implementation). /// The folder node identifier. /// The parent folder node identifier. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 1a9760d7..3de0a8b3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -253,6 +253,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// The timestamp of the state change. public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) => Calls.Enqueue($"WA:{alarmNodeId}"); + /// Records a materialise-alarm-condition call. + /// The alarm node ID (== ScriptedAlarmId). + /// The equipment folder node ID. + /// The condition display name. + /// The domain alarm type. + /// The domain severity. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) + => Calls.Enqueue($"MA:{alarmNodeId}"); /// Records a folder ensure call. /// The folder node ID. /// The parent node ID, or null if this is a root folder. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index f89033a2..a81387d8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -176,6 +176,14 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime ts) => AlarmQueue.Enqueue((alarmNodeId, active, acknowledged, ts)); + /// Materialises an alarm condition (no-op in test). + /// The alarm node ID. + /// The equipment folder node ID. + /// The condition display name. + /// The domain alarm type. + /// The domain severity. + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { } + /// Ensures a folder exists (no-op in test). /// The OPC UA folder node identifier. /// The parent folder node identifier, or null for root.