feat(v3-batch4-wp4): multi-notifier native alarms (single ReportEvent → raw + equipment notifiers) + teardown symmetry
Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw realm); wire the single condition as an SDK event notifier of each referencing equipment's UNS folder so one ReportEvent fans to every root without re-reporting per root (which would break Server-object dedup + Part 9 ack correlation). - New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink, forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink + NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the DeferredSinkForwardingReflectionTests realm + forwarding guards + a hand-written forward test. - OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent EnsureFolderIsEventNotifier per equipment folder; tracked per condition in _alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no inverse-notifier entry leaks across redeploys. - AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath. - AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts renders the referencing-equipment list as display metadata. - Un-skipped + rewrote the native-alarm dark tests (DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a re-trip) + applier wiring test. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
|
||||
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
|
||||
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
|
||||
/// <param name="ReferencingEquipmentPaths">v3 Batch 4 (multi-notifier native alarms) — the (possibly empty) list of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference the alarm's backing raw tag. A native alarm is a SINGLE Part 9 condition materialised once at the raw tag (its <see cref="AlarmId"/> is the RawPath); the same condition fans events to every referencing equipment's UNS folder via SDK notifiers, and this list is carried so <c>/alerts</c> shows the one condition row with all its referencing equipment as display metadata. Empty for scripted alarms (they are per-equipment) and for a native alarm whose raw tag no equipment references. Defaults empty so every existing producer + rolling-restart deserialization keeps compiling / working.</param>
|
||||
public sealed record AlarmTransitionEvent(
|
||||
string AlarmId,
|
||||
string EquipmentPath,
|
||||
@@ -28,4 +29,5 @@ public sealed record AlarmTransitionEvent(
|
||||
DateTime TimestampUtc,
|
||||
string AlarmTypeName = "AlarmCondition",
|
||||
string? Comment = null,
|
||||
bool? HistorizeToAveva = null);
|
||||
bool? HistorizeToAveva = null,
|
||||
IReadOnlyList<string>? ReferencingEquipmentPaths = null);
|
||||
|
||||
@@ -34,6 +34,13 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||
|
||||
/// <inheritdoc />
|
||||
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
|
||||
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
|
||||
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
=> _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||
|
||||
@@ -51,6 +51,28 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// live in.</param>
|
||||
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
|
||||
/// (<paramref name="alarmNodeId"/>, materialised at the raw tag via <see cref="MaterialiseAlarmCondition"/>
|
||||
/// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
|
||||
/// the condition's <b>single</b> <c>ReportEvent</c> fans one event to every referencing equipment root
|
||||
/// WITHOUT re-reporting per root (distinct EventIds would break Server-object dedup + Part 9 ack
|
||||
/// correlation). Per folder the sink wires the SDK's bidirectional notifier pattern
|
||||
/// (<c>alarm.AddNotifier(isInverse:true, folder)</c> + <c>folder.AddNotifier(isInverse:false, alarm)</c>)
|
||||
/// and promotes the folder to an event notifier. <b>Idempotent</b> (a re-wire of the same pair updates,
|
||||
/// never duplicates); a <b>missing endpoint is a no-op</b> (logged, never thrown) so a mid-rebuild race
|
||||
/// can't fault a deploy. The sink tracks each wired pair so a later rebuild / condition-removal /
|
||||
/// equipment-subtree-removal tears the notifier down bidirectionally (no inverse-notifier entry leaks
|
||||
/// across redeploys).
|
||||
/// </summary>
|
||||
/// <param name="alarmNodeId">The native alarm condition's node id (== the backing tag's RawPath).</param>
|
||||
/// <param name="alarmRealm">The namespace realm the condition lives in (Raw for native alarms).</param>
|
||||
/// <param name="notifierFolderNodeIds">The equipment folder node ids (their <c>s=</c> ids) to wire as
|
||||
/// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.</param>
|
||||
/// <param name="notifierFolderRealm">The namespace realm the notifier folders live in (Uns for equipment folders).</param>
|
||||
void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
|
||||
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
|
||||
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
|
||||
@@ -147,6 +169,9 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
/// <inheritdoc />
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
|
||||
|
||||
|
||||
@@ -64,7 +64,22 @@ else
|
||||
<tr>
|
||||
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
|
||||
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
|
||||
<td><span class="mono small">@e.EquipmentPath</span></td>
|
||||
<td>
|
||||
<span class="mono small">@e.EquipmentPath</span>
|
||||
@* v3 Batch 4 (multi-notifier native alarms): one condition row carries the list
|
||||
of referencing equipment (Area/Line/Equipment) as display metadata. Shown only
|
||||
when the producer populated it (native alarms whose raw tag ≥1 equipment
|
||||
references); scripted alarms + unreferenced native alarms leave it empty. *@
|
||||
@if (e.ReferencingEquipmentPaths is { Count: > 0 } refs)
|
||||
{
|
||||
<div class="text-muted small" title="Referencing equipment">
|
||||
@foreach (var p in refs)
|
||||
{
|
||||
<span class="chip chip-idle" style="font-size:0.72rem; margin:1px">@p</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
|
||||
<td class="num">@e.Severity</td>
|
||||
<td>@e.User</td>
|
||||
|
||||
@@ -687,13 +687,41 @@ public sealed class AddressSpaceApplier
|
||||
if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++;
|
||||
}
|
||||
|
||||
// v3 Batch 4 WP4 — reverse map (equipment UNS folder PATH → EquipmentId) so a native alarm's
|
||||
// ReferencingEquipmentPaths (Area/Line/Equipment name paths, from the composer) resolve to the
|
||||
// EquipmentId the equipment folders were materialised under by MaterialiseHierarchy. Built lazily only
|
||||
// when at least one raw tag carries an alarm (the common no-alarm deploy pays nothing).
|
||||
IReadOnlyDictionary<string, string>? equipIdByFolderPath = null;
|
||||
|
||||
foreach (var t in composition.RawTags)
|
||||
{
|
||||
if (t.Alarm is not null)
|
||||
{
|
||||
// Native alarm tag → a single Part 9 condition node at the RawPath (ConditionId = RawPath).
|
||||
// Parent is its device/group folder. Multi-notifier fan-out to referencing equipment is WP4.
|
||||
if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw)) failed++;
|
||||
// Parent is its device/group folder. The single condition instance is materialised ONCE here.
|
||||
if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw))
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
else if (t.ReferencingEquipmentPaths.Count > 0)
|
||||
{
|
||||
// WP4 multi-notifier fan-out: wire the SINGLE condition as an event notifier of each
|
||||
// referencing equipment's UNS folder, so one ReportEvent reaches every referencing equipment
|
||||
// (never re-reported per root). Resolve the folder PATHS to their EquipmentId folder NodeIds
|
||||
// (the id scheme MaterialiseHierarchy created the equipment folders under).
|
||||
equipIdByFolderPath ??= BuildEquipmentIdByFolderPath(composition);
|
||||
var equipFolderNodeIds = t.ReferencingEquipmentPaths
|
||||
.Select(p => equipIdByFolderPath!.GetValueOrDefault(p))
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Select(id => id!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (equipFolderNodeIds.Count > 0 &&
|
||||
!SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns))
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1037,6 +1065,49 @@ public sealed class AddressSpaceApplier
|
||||
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Wire a native alarm condition's extra equipment-folder notifiers (WP4 multi-notifier),
|
||||
/// swallowing (and Warning-logging) any sink fault. Returns <c>true</c> on success, <c>false</c> when the
|
||||
/// sink threw — callers tally the false into their pass's failed-node count (archreview 01/S-1).</summary>
|
||||
/// <returns><c>true</c> when the notifiers were wired; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeWireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
{
|
||||
try { _sink.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WireAlarmNotifiers threw for {Node}", alarmNodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 WP4 — build the reverse map <c>equipment UNS folder PATH (Area/Line/Equipment) →
|
||||
/// EquipmentId</c>. The composer emits a native alarm's <see cref="RawTagPlan.ReferencingEquipmentPaths"/>
|
||||
/// as name paths (<c>V3NodeIds.Uns(areaName, lineName, equipName)</c>), but the equipment folders were
|
||||
/// materialised under their logical <c>EquipmentId</c> by <see cref="MaterialiseHierarchy"/>, so the
|
||||
/// multi-notifier wiring must translate path → id. This inverts the composer's own equipment-folder-path
|
||||
/// construction EXACTLY (area/line/equipment DisplayName == the UNS level Name, so the paths match
|
||||
/// byte-for-byte); an invalid segment throws in <see cref="V3NodeIds.Uns"/> and is skipped (the same
|
||||
/// drop the composer applies), so an entry the composer produced always resolves here.
|
||||
/// </summary>
|
||||
/// <param name="composition">The composition carrying the UNS topology + equipment nodes.</param>
|
||||
/// <returns>A map from each resolvable equipment folder path to its EquipmentId.</returns>
|
||||
private static IReadOnlyDictionary<string, string> BuildEquipmentIdByFolderPath(AddressSpaceComposition composition)
|
||||
{
|
||||
var areaName = composition.UnsAreas
|
||||
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DisplayName, StringComparer.Ordinal);
|
||||
var lineByid = composition.UnsLines
|
||||
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().DisplayName), StringComparer.Ordinal);
|
||||
|
||||
var map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var e in composition.EquipmentNodes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(e.UnsLineId)) continue;
|
||||
if (!lineByid.TryGetValue(e.UnsLineId, out var line)) continue;
|
||||
if (!areaName.TryGetValue(line.UnsAreaId, out var aName)) continue;
|
||||
try { map[V3NodeIds.Uns(aName, line.DisplayName, e.DisplayName)] = e.EquipmentId; }
|
||||
catch (ArgumentException) { /* invalid segment — dropped, mirroring the composer */ }
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
|
||||
|
||||
@@ -71,6 +71,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
|
||||
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
|
||||
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
|
||||
/// <summary>v3 Batch 4 (WP4 multi-notifier native alarms): tracks, per materialised native-alarm condition,
|
||||
/// the equipment folders wired as EXTRA event-notifier roots for it (via <see cref="WireAlarmNotifiers"/>).
|
||||
/// A native alarm is a single condition at the raw tag (ConditionId = RawPath); its single
|
||||
/// <c>ReportEvent</c> fans one event to every referencing equipment's UNS folder through the SDK notifier
|
||||
/// list (never re-reported per root). Keyed by the condition's full (namespace-qualified) NodeId string
|
||||
/// (matches <see cref="_alarmConditions"/>' keys). On rebuild / condition-removal / equipment-subtree-removal
|
||||
/// each wired pair is torn down bidirectionally (<c>RemoveNotifier(bidirectional:true)</c>) so an
|
||||
/// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
|
||||
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
|
||||
private readonly Dictionary<string, AlarmNotifierWiring> _alarmNotifierWiring = new(StringComparer.Ordinal);
|
||||
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
|
||||
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
|
||||
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
|
||||
@@ -340,6 +350,55 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
internal FolderState? TryGetFolder(string nodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
|
||||
_folders.TryGetValue(MapKey(realm, nodeId), out var folder) ? folder : null;
|
||||
|
||||
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
|
||||
/// materialised alarm condition <paramref name="alarmNodeId"/> — for a native alarm this is the number of
|
||||
/// referencing equipment folders wired via <see cref="WireAlarmNotifiers"/>. Zero when the condition is
|
||||
/// absent. Used by the multi-notifier + teardown-symmetry tests to prove no notifier entry leaks/duplicates
|
||||
/// across redeploys.</summary>
|
||||
/// <param name="alarmNodeId">The alarm condition node identifier.</param>
|
||||
/// <param name="realm">The realm the condition lives in (Raw for native alarms).</param>
|
||||
/// <returns>The number of notifier entries on the condition.</returns>
|
||||
internal int AlarmNotifierCount(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (!_alarmConditions.TryGetValue(MapKey(realm, alarmNodeId), out var alarm)) return 0;
|
||||
var list = new List<NodeState.Notifier>();
|
||||
alarm.GetNotifiers(SystemContext, list);
|
||||
return list.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): the count of event-notifier entries on the
|
||||
/// materialised folder <paramref name="folderNodeId"/> — for an equipment folder wired as an alarm notifier
|
||||
/// this counts the inverse links back to its condition(s). Zero when the folder is absent.</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
/// <param name="realm">The realm the folder lives in (Uns for equipment folders).</param>
|
||||
/// <returns>The number of notifier entries on the folder.</returns>
|
||||
internal int FolderNotifierCount(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (!_folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)) return 0;
|
||||
var list = new List<NodeState.Notifier>();
|
||||
folder.GetNotifiers(SystemContext, list);
|
||||
return list.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test/diagnostic accessor (v3 Batch 4 WP4): true when <paramref name="folderNodeId"/> is
|
||||
/// registered as a root (Server-object) event notifier — i.e. it was promoted via
|
||||
/// <see cref="EnsureFolderIsEventNotifier"/> and not yet torn down.</summary>
|
||||
/// <param name="folderNodeId">The folder node identifier.</param>
|
||||
/// <param name="realm">The realm the folder lives in.</param>
|
||||
/// <returns>True when the folder is a registered root notifier.</returns>
|
||||
internal bool IsRootNotifier(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
{
|
||||
lock (Lock)
|
||||
return _folders.TryGetValue(MapKey(realm, folderNodeId), out var folder)
|
||||
&& _notifierFolders.ContainsKey(folder.NodeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
||||
/// variable node on first call; subsequent calls update Value + StatusCode +
|
||||
@@ -825,6 +884,91 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
|
||||
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
|
||||
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
|
||||
/// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
|
||||
/// the instance changes).</summary>
|
||||
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List<FolderState> Folders);
|
||||
|
||||
/// <inheritdoc cref="IOpcUaAddressSpaceSink.WireAlarmNotifiers"/>
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
|
||||
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
|
||||
ArgumentNullException.ThrowIfNull(notifierFolderNodeIds);
|
||||
if (notifierFolderNodeIds.Count == 0) return;
|
||||
EnsureAddressSpaceCreated();
|
||||
|
||||
var alarmKey = MapKey(alarmRealm, alarmNodeId);
|
||||
lock (Lock)
|
||||
{
|
||||
// The condition must already be materialised (MaterialiseAlarmCondition ran first in the same
|
||||
// apply pass). A miss ⇒ a mid-rebuild race cleared it: no-op (logged), never throw — a deploy must
|
||||
// not fault on the notifier wiring.
|
||||
if (!_alarmConditions.TryGetValue(alarmKey, out var alarm))
|
||||
{
|
||||
// This CustomNodeManager2 carries no ILogger; log through the SDK's static trace (see the
|
||||
// ReportEvent catch below for the same pattern). Utils.LogInfo is [Obsolete] in 1.5.378 in
|
||||
// favour of an ITelemetryContext this manager doesn't wire — suppress the deprecation.
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: condition {0} not materialised — skipping notifier wiring", alarmKey);
|
||||
#pragma warning restore CS0618
|
||||
return;
|
||||
}
|
||||
|
||||
// Get-or-create the wiring entry; a rebuild recreated the AlarmConditionState instance, so reset
|
||||
// the entry (the old folder list referenced the discarded instance) when the instance differs.
|
||||
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm))
|
||||
{
|
||||
wiring = new AlarmNotifierWiring(alarm, new List<FolderState>());
|
||||
_alarmNotifierWiring[alarmKey] = wiring;
|
||||
}
|
||||
|
||||
foreach (var folderNodeId in notifierFolderNodeIds)
|
||||
{
|
||||
if (!_folders.TryGetValue(MapKey(notifierFolderRealm, folderNodeId), out var folder))
|
||||
{
|
||||
// Referencing-equipment folder not (yet) materialised — skip this one (logged, never thrown).
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: notifier folder {0} for condition {1} not present — skipped",
|
||||
folderNodeId, alarmKey);
|
||||
#pragma warning restore CS0618
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normative SDK pattern (design §"OPC UA address space + runtime binding"): a single
|
||||
// ReportEvent on the condition bubbles through its HasComponent parent chain PLUS every inverse
|
||||
// entry in its notifier list, so one event fans to every wired root WITHOUT re-reporting per
|
||||
// root (distinct EventIds would break Server-object dedup + Part 9 ack correlation).
|
||||
// alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder
|
||||
// folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link
|
||||
// EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent)
|
||||
// AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply)
|
||||
// updates rather than duplicates.
|
||||
alarm.AddNotifier(SystemContext, null, isInverse: true, folder);
|
||||
folder.AddNotifier(SystemContext, null, isInverse: false, alarm);
|
||||
EnsureFolderIsEventNotifier(folder);
|
||||
|
||||
if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tear down (bidirectionally) every notifier wired for the condition at <paramref name="alarmKey"/>
|
||||
/// and drop its tracking entry. MUST be called under <c>Lock</c>. Used on condition-removal + full rebuild
|
||||
/// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.</summary>
|
||||
private void UnwireAlarmNotifiers(string alarmKey)
|
||||
{
|
||||
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return;
|
||||
foreach (var folder in wiring.Folders)
|
||||
{
|
||||
// bidirectional:true also removes the inverse entry the folder holds back to the condition.
|
||||
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
|
||||
}
|
||||
_alarmNotifierWiring.Remove(alarmKey);
|
||||
}
|
||||
|
||||
/// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> is a NATIVE
|
||||
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
|
||||
/// inbound Acknowledge to the driver instead of the scripted engine.</summary>
|
||||
@@ -1973,6 +2117,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
alarm.Parent?.RemoveChild(alarm);
|
||||
PredefinedNodes?.Remove(alarm.NodeId);
|
||||
}
|
||||
// v3 Batch 4 WP4: tear down every native-alarm→equipment-folder notifier pair bidirectionally
|
||||
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
|
||||
// rebuild. The condition + folder NodeState objects still exist here (the loops above/below only
|
||||
// detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the
|
||||
// tracking map so the re-materialise + re-wire on the next apply starts from a clean slate.
|
||||
foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList())
|
||||
UnwireAlarmNotifiers(alarmKey);
|
||||
|
||||
_alarmConditions.Clear();
|
||||
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
|
||||
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
|
||||
@@ -2043,6 +2195,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
|
||||
// equipment goes).
|
||||
if (!_alarmConditions.TryRemove(key, out var condition)) return false;
|
||||
// v3 Batch 4 WP4: unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
|
||||
// the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without
|
||||
// this they would keep a dangling inverse-notifier entry to the removed condition.
|
||||
UnwireAlarmNotifiers(key);
|
||||
condition.Parent?.RemoveChild(condition);
|
||||
PredefinedNodes?.Remove(condition.NodeId);
|
||||
_nativeAlarmNodeIds.Remove(key);
|
||||
@@ -2095,6 +2251,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_nativeAlarmNodeIds.Remove(id);
|
||||
}
|
||||
|
||||
// v3 Batch 4 WP4: a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this
|
||||
// UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra
|
||||
// event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop,
|
||||
// else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The
|
||||
// condition's own scoped removal — if its raw tag is also removed this apply — runs later via
|
||||
// RemoveAlarmConditionNode; this only reaches surviving conditions.)
|
||||
foreach (var wiring in _alarmNotifierWiring.Values)
|
||||
{
|
||||
foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList())
|
||||
{
|
||||
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
|
||||
wiring.Folders.Remove(folder);
|
||||
}
|
||||
}
|
||||
|
||||
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
|
||||
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
|
||||
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
|
||||
|
||||
@@ -32,6 +32,10 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
=> _nodeManager.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
|
||||
|
||||
@@ -63,6 +63,25 @@ public class DeferredAddressSpaceSinkTests
|
||||
inner.RebuildCalled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void After_SetSink_WireAlarmNotifiers_is_forwarded_with_all_args()
|
||||
{
|
||||
// v3 Batch 4 WP4: without this forward the native-alarm multi-notifier fan-out ships INERT on every
|
||||
// driver-role host (actors inject THIS wrapper, not the inner sink) — the F10b / PR#423 trap class.
|
||||
var inner = new SpySink();
|
||||
var sink = new DeferredAddressSpaceSink();
|
||||
sink.SetSink(inner);
|
||||
|
||||
sink.WireAlarmNotifiers("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw,
|
||||
new[] { "EQ-1", "EQ-2" }, AddressSpaceRealm.Uns);
|
||||
|
||||
inner.WireAlarmNotifiersArgs.ShouldNotBeNull();
|
||||
inner.WireAlarmNotifiersArgs!.Value.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
|
||||
inner.WireAlarmNotifiersArgs.Value.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
inner.WireAlarmNotifiersArgs.Value.Folders.ShouldBe(new[] { "EQ-1", "EQ-2" });
|
||||
inner.WireAlarmNotifiersArgs.Value.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
}
|
||||
|
||||
// ---------- ISurgicalAddressSpaceSink forwarding ----------
|
||||
|
||||
[Fact]
|
||||
@@ -185,6 +204,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
{
|
||||
public bool WriteValueCalled { get; private set; }
|
||||
public bool RebuildCalled { get; private set; }
|
||||
public (string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> Folders, AddressSpaceRealm FolderRealm)? WireAlarmNotifiersArgs { get; private set; }
|
||||
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
=> WriteValueCalled = true;
|
||||
@@ -195,6 +215,8 @@ public class DeferredAddressSpaceSinkTests
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void RebuildAddressSpace() => RebuildCalled = true;
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
=> WireAlarmNotifiersArgs = (alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
@@ -210,6 +232,7 @@ public class DeferredAddressSpaceSinkTests
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
|
||||
+1
@@ -189,6 +189,7 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
}
|
||||
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,6 +338,7 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,49 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
c.Parent.ShouldBe("Plant/A/dev");
|
||||
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
c.IsNative.ShouldBeTrue();
|
||||
// No referencing equipment ⇒ no multi-notifier wiring.
|
||||
sink.NotifierWirings.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterialiseRawSubtree_alarm_tag_wires_a_notifier_per_referencing_equipment_folder()
|
||||
{
|
||||
var sink = new RealmRecordingSink();
|
||||
// UNS topology so the alarm's referencing-equipment PATHS (Area/Line/Equipment) resolve to the
|
||||
// EquipmentId the equipment folders were materialised under. Two equipment reference the one alarm tag.
|
||||
var composition = new AddressSpaceComposition(
|
||||
new[] { new UnsAreaProjection("a1", "filling") },
|
||||
new[] { new UnsLineProjection("l1", "a1", "line1"), new UnsLineProjection("l2", "a1", "line2") },
|
||||
new[]
|
||||
{
|
||||
new EquipmentNode("EQ-1", "station1", "l1"),
|
||||
new EquipmentNode("EQ-2", "station2", "l2"),
|
||||
},
|
||||
Array.Empty<DriverInstancePlan>(),
|
||||
Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
RawTags = new[]
|
||||
{
|
||||
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
|
||||
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
|
||||
// The composer emits Area/Line/Equipment NAME paths here.
|
||||
ReferencingEquipmentPaths: new[] { "filling/line1/station1", "filling/line2/station2" }),
|
||||
},
|
||||
};
|
||||
|
||||
NewApplier(sink).MaterialiseRawSubtree(composition);
|
||||
|
||||
// The single condition is materialised ONCE at the RawPath (Raw realm)...
|
||||
var c = sink.Conditions.ShouldHaveSingleItem();
|
||||
c.NodeId.ShouldBe("Plant/A/dev/OverTemp");
|
||||
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
// ...and wired as a notifier of BOTH referencing equipment folders — resolved from the name paths to the
|
||||
// logical EquipmentId folder NodeIds, in the Uns realm. ONE wiring call (single condition), not per-root.
|
||||
var w = sink.NotifierWirings.ShouldHaveSingleItem();
|
||||
w.AlarmNodeId.ShouldBe("Plant/A/dev/OverTemp");
|
||||
w.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
w.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,6 +199,7 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
|
||||
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
|
||||
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
|
||||
public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { get; } = new();
|
||||
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
=> Folders.Add((folderNodeId, parentNodeId, realm));
|
||||
@@ -166,6 +210,9 @@ public sealed class AddressSpaceApplierRawUnsTests
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
|
||||
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
|
||||
=> NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm));
|
||||
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
|
||||
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
|
||||
|
||||
|
||||
@@ -2307,6 +2307,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
@@ -2332,6 +2333,7 @@ public sealed class AddressSpaceApplierTests
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
/// <summary>No-op NodeAdded model-change announcement.</summary>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
@@ -2381,6 +2383,7 @@ public sealed class AddressSpaceApplierTests
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <summary>No-op NodeAdded model-change announcement.</summary>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"NA:{affectedNodeId}");
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
@@ -299,6 +300,7 @@ public sealed class DeferredAddressSpaceSinkTests
|
||||
public void RebuildAddressSpace() { }
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 (B4-WP4) — the native-alarm <b>multi-notifier</b> wiring +
|
||||
/// <b>teardown symmetry</b> on <see cref="OtOpcUaNodeManager"/>. A native alarm is a SINGLE Part 9
|
||||
/// condition materialised once at its raw tag (ConditionId = RawPath, Raw realm);
|
||||
/// <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> wires that one condition as an event notifier of
|
||||
/// EACH referencing equipment's UNS folder so one <c>ReportEvent</c> fans one event to every referencing
|
||||
/// equipment (never re-reported per root). The obligation these tests lock in: the wiring is idempotent,
|
||||
/// a missing folder is a safe skip, and every teardown path (condition-removal / equipment-subtree-removal
|
||||
/// / full rebuild + re-wire) removes the notifier pair BIDIRECTIONALLY so no inverse-notifier entry leaks
|
||||
/// across redeploys (exactly N notifiers after a re-trip, not 2N).
|
||||
/// </summary>
|
||||
public sealed class NodeManagerMultiNotifierAlarmTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private const string RawAlarm = "Plant/Modbus/dev1/temp_hi";
|
||||
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||
private const string Equip1 = "EQ-filling-line1-station1";
|
||||
private const string Equip2 = "EQ-filling-line2-station2";
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"otopcua-multi-notifier-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>Materialise the raw device folder + the single native condition + two equipment folders, then
|
||||
/// wire the condition as a notifier of both. Returns the node manager.</summary>
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootWithTwoEquipmentAsync()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
// Raw device folder (the condition's HasComponent parent) + the single native condition at the RawPath.
|
||||
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
|
||||
// Two referencing equipment folders (UNS realm).
|
||||
nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
||||
nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
|
||||
return (host, nm);
|
||||
}
|
||||
|
||||
/// <summary>The single condition is wired as a notifier of EACH referencing equipment folder: the condition
|
||||
/// carries one inverse-notifier entry per folder, each folder carries the inverse back to the condition, and
|
||||
/// each folder becomes a root (Server-object) event notifier.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WireAlarmNotifiers_wires_the_single_condition_to_each_equipment_folder()
|
||||
{
|
||||
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||
|
||||
// One inverse-notifier entry per equipment folder on the single condition.
|
||||
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||
// Each equipment folder holds the inverse entry back to the condition + is a root notifier.
|
||||
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
nm.IsRootNotifier(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Re-wiring the SAME pairs (an idempotent re-apply of the raw subtree pass) does not duplicate the
|
||||
/// notifier entries — the SDK dedups by node reference and the tracking dedups its list.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WireAlarmNotifiers_is_idempotent_no_duplicate_on_re_wire()
|
||||
{
|
||||
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||
|
||||
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A referencing equipment folder that is not (yet) materialised is skipped (no throw); the present
|
||||
/// folders are still wired. A missing condition is likewise a no-op.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task WireAlarmNotifiers_missing_folder_or_condition_is_a_safe_skip()
|
||||
{
|
||||
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||
|
||||
// "EQ-missing" was never materialised — it is skipped; Equip1 is still wired.
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, "EQ-missing" }, AddressSpaceRealm.Uns);
|
||||
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
|
||||
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
|
||||
// An unmaterialised condition id is a no-op (never throws).
|
||||
Should.NotThrow(() =>
|
||||
nm.WireAlarmNotifiers("Plant/Modbus/dev1/does_not_exist", AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns));
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Removing the condition in place (surgical raw-alarm-tag removal) tears the notifier pairs down
|
||||
/// BIDIRECTIONALLY: the surviving equipment folders lose their inverse-notifier entry back to the removed
|
||||
/// condition (no dangling reference).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task RemoveAlarmConditionNode_unwires_notifiers_from_surviving_folders()
|
||||
{
|
||||
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
|
||||
nm.RemoveAlarmConditionNode(RawAlarm, AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||
|
||||
// Condition gone; the surviving equipment folders no longer reference it (bidirectional teardown).
|
||||
nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(0);
|
||||
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Removing one referencing equipment's subtree unwires ONLY that folder from the surviving raw
|
||||
/// condition (which lives in the Raw realm and is untouched by a UNS subtree removal): the condition drops
|
||||
/// exactly one notifier and keeps the other.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task RemoveEquipmentSubtree_unwires_only_that_folder_from_surviving_condition()
|
||||
{
|
||||
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||
|
||||
nm.RemoveEquipmentSubtree(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
|
||||
// The raw condition SURVIVES (Raw realm) and now notifies only the remaining equipment folder.
|
||||
nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
|
||||
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
|
||||
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Teardown-symmetry / "exactly one copy after a re-trip": a full rebuild + re-materialise +
|
||||
/// re-wire leaves EXACTLY the same notifier count (2), not a doubled/leaked set — the rebuild unwired the
|
||||
/// prior notifier pairs bidirectionally before dropping the nodes.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task Rebuild_then_rewire_has_no_leaked_notifier_duplicates()
|
||||
{
|
||||
var (host, nm) = await BootWithTwoEquipmentAsync();
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||
|
||||
// Redeploy (the full-rebuild path).
|
||||
nm.RebuildAddressSpace();
|
||||
nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
|
||||
// Re-materialise the whole thing + re-wire (what the applier does every apply).
|
||||
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
|
||||
nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
||||
nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
|
||||
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
|
||||
|
||||
// Exactly 2 again — no leaked inverse-notifier entries carried across the rebuild.
|
||||
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
|
||||
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.MultiNotifierTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.MultiNotifierTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -346,6 +346,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
|
||||
|
||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId);
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
+51
-56
@@ -19,21 +19,22 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the inbound native-condition <b>acknowledge</b> routing wired into
|
||||
/// <see cref="DriverHostActor"/> (H6d): an OPC UA client Acknowledges a NATIVE condition, the
|
||||
/// node manager invokes <c>NativeAlarmAckRouter</c>, and the host (NEXT task) Tells a
|
||||
/// <see cref="DriverHostActor.RouteNativeAlarmAck"/> in. The host resolves the condition NodeId →
|
||||
/// owning <c>(DriverInstanceId, FullName)</c> via the <c>_driverRefByAlarmNodeId</c> inverse map
|
||||
/// (built alongside the alarm forward map in <c>PushDesiredSubscriptions</c>), applies the SAME
|
||||
/// v3 Batch 4 (B4-WP4) — the inbound native-condition <b>acknowledge</b> routing wired into
|
||||
/// <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. An OPC UA client Acknowledges
|
||||
/// a NATIVE condition (materialised at the raw tag, ConditionId = its RawPath); the node manager invokes
|
||||
/// <c>NativeAlarmAckRouter</c> and the host receives a <see cref="DriverHostActor.RouteNativeAlarmAck"/>.
|
||||
/// The host resolves the condition NodeId (== the RawPath) → owning <c>(DriverInstanceId, RawPath)</c> via
|
||||
/// the <c>_driverRefByAlarmNodeId</c> inverse map (built alongside the alarm forward map in
|
||||
/// <c>PushDesiredSubscriptions</c> from the alarm-bearing <c>composition.RawTags</c>), applies the SAME
|
||||
/// primary gate the inbound write path uses, and routes to the owning driver child's
|
||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/> carrying the principal.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors <c>DriverHostActorWriteRoutingTests</c>: a real apply through the existing harness
|
||||
/// spawns a real (non-stubbed) <see cref="DriverInstanceActor"/> child backed by a recording
|
||||
/// <see cref="IAlarmSource"/> driver, so the inverse map is populated authentically and the
|
||||
/// forwarded acknowledge request can be observed. The seeded tag carries an <c>alarm</c> object so
|
||||
/// it materialises as a Part 9 condition (folder-scoped condition NodeId), not a value variable.
|
||||
/// Mirrors <c>DriverHostActorLiveValueTests</c>: a real apply through the harness spawns a real
|
||||
/// (non-stubbed) <see cref="DriverInstanceActor"/> child backed by a recording <see cref="IAlarmSource"/>
|
||||
/// driver (DriverType "GalaxyMxGateway", Enabled), so the inverse map is populated authentically and the
|
||||
/// forwarded acknowledge request can be observed. The seeded raw tag carries an <c>alarm</c> object so it
|
||||
/// materialises as a Part 9 condition at its RawPath, not a value variable.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTestBase
|
||||
@@ -42,31 +43,31 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped condition NodeId
|
||||
/// forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
|
||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, with <c>ConditionId == FullName</c>, the operator
|
||||
/// principal, and the comment.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
// The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "gw" / Device "dev1" / Tag.
|
||||
private const string AlarmRawPath = "Plant/gw/dev1/temp_hi";
|
||||
|
||||
/// <summary>On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped raw condition NodeId
|
||||
/// (== the RawPath) forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
|
||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, correlated on the RawPath, with the operator principal + the
|
||||
/// comment.</summary>
|
||||
[Fact]
|
||||
public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||
// One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi"
|
||||
// → condition NodeId "eq-1/temp_hi".
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||
|
||||
// The driver received exactly one acknowledge, correlated on its wire-ref FullName, with principal.
|
||||
// The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal.
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
recorder.Acks.Count.ShouldBe(1);
|
||||
recorder.Acks[0].ConditionId.ShouldBe("Temp.HiHi");
|
||||
recorder.Acks[0].SourceNodeId.ShouldBe("Temp.HiHi");
|
||||
recorder.Acks[0].ConditionId.ShouldBe(AlarmRawPath);
|
||||
recorder.Acks[0].SourceNodeId.ShouldBe(AlarmRawPath);
|
||||
recorder.Acks[0].Comment.ShouldBe("cmt");
|
||||
recorder.Acks[0].OperatorUser.ShouldBe("alice");
|
||||
}, duration: Timeout);
|
||||
@@ -79,27 +80,24 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/does-not-exist", "cmt", "alice"));
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice"));
|
||||
|
||||
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack.
|
||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
||||
}
|
||||
|
||||
/// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
|
||||
/// driver's <see cref="IAlarmSource.AcknowledgeAsync"/> is NOT called — a secondary keeps its address
|
||||
/// space warm but must not push commands to the shared upstream alarm system.</summary>
|
||||
/// driver's <see cref="IAlarmSource.AcknowledgeAsync"/> is NOT called.</summary>
|
||||
[Fact]
|
||||
public void RouteNativeAlarmAck_on_non_primary_is_dropped()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var actor = SpawnHostAndApply(db, deploymentId, recorder);
|
||||
|
||||
@@ -112,7 +110,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
},
|
||||
CorrelationId.NewId()));
|
||||
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
|
||||
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
|
||||
|
||||
// No ack reached the driver — the gate short-circuited before the inverse-map lookup.
|
||||
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
|
||||
@@ -137,51 +135,48 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
|
||||
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object so
|
||||
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null <c>EquipmentTagAlarmInfo</c> —
|
||||
/// making the tag a condition (folder-scoped condition NodeId) rather than a value variable. The
|
||||
/// <c>DriverInstances</c> row carries a non-Windows-only <c>DriverType</c> ("GalaxyMxGateway") + an
|
||||
/// Enabled flag so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned.
|
||||
/// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
||||
/// DriverInstance(RawFolderId, Name "gw", DriverType "GalaxyMxGateway", Enabled) → Device "dev1" → Tag)
|
||||
/// with the tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
|
||||
/// <c>EquipmentTagAlarmInfo</c> — making the raw tag a Part 9 condition at its RawPath. The non-Windows
|
||||
/// <c>DriverType</c> + Enabled flag spawn a REAL <see cref="DriverInstanceActor"/> child.
|
||||
/// </summary>
|
||||
private static DeploymentId SeedDeploymentWithAlarmTag(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
string Equip, string Driver, string FullName, string? Folder, string Name)
|
||||
private static DeploymentId SeedV3AlarmDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string Driver, string Tag)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||
},
|
||||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
DriverInstanceRowId = Guid.NewGuid(),
|
||||
DriverInstanceId = Driver,
|
||||
Name = Driver,
|
||||
RawFolderId = "rf-plant",
|
||||
Name = "gw",
|
||||
DriverType = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed)
|
||||
Enabled = true,
|
||||
DriverConfig = "{}",
|
||||
NamespaceId = "ns-eq",
|
||||
ClusterId = "c1",
|
||||
},
|
||||
},
|
||||
Devices = new[]
|
||||
{
|
||||
new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
|
||||
},
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-0",
|
||||
EquipmentId = Equip,
|
||||
DriverInstanceId = Driver,
|
||||
Name,
|
||||
FolderPath = Folder,
|
||||
DeviceId = $"{Driver}:dev1",
|
||||
TagGroupId = (string?)null,
|
||||
Name = Tag,
|
||||
DataType = "Boolean",
|
||||
TagConfig = JsonSerializer.Serialize(new
|
||||
{
|
||||
FullName,
|
||||
alarm = new { alarmType = "OffNormalAlarm", severity = 700 },
|
||||
}),
|
||||
AccessLevel = 0, // TagAccessLevel.Read
|
||||
TagConfig = JsonSerializer.Serialize(new { alarm = new { alarmType = "OffNormalAlarm", severity = 700 } }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+95
-122
@@ -24,22 +24,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the equipment-tag <b>native-alarm routing</b> wired into <see cref="DriverHostActor"/>
|
||||
/// (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as
|
||||
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> keyed by the alarm source's
|
||||
/// <c>SourceNodeId</c> (the equipment tag's wire-ref <c>FullName</c>), but the materialised condition
|
||||
/// lives at a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an apply, the
|
||||
/// host's <c>_alarmNodeIdByDriverRef</c> map (built only from alarm-bearing EquipmentTags) resolves
|
||||
/// <c>(DriverInstanceId, SourceNodeId)</c> to that NodeId, the <c>NativeAlarmProjector</c> projects
|
||||
/// the transition into a full <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the
|
||||
/// publish actor an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> — the same message scripted
|
||||
/// alarms use.
|
||||
/// v3 Batch 4 (B4-WP4) — the equipment-tag <b>native-alarm routing</b> wired into
|
||||
/// <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. A native alarm is a SINGLE
|
||||
/// Part 9 condition materialised at the RAW tag (ConditionId = its RawPath, Raw realm) — NOT a folder-scoped
|
||||
/// equipment-tag NodeId. After an apply, the host's <c>_alarmNodeIdByDriverRef</c> map (built from the
|
||||
/// alarm-bearing <c>composition.RawTags</c>) resolves <c>(DriverInstanceId, RawPath)</c> to the raw
|
||||
/// condition NodeId, the <c>NativeAlarmProjector</c> projects the transition into a full
|
||||
/// <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the publish actor a single
|
||||
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (Raw realm) — the fan-out to referencing equipment
|
||||
/// happens at the SDK-notifier level (WP4 node-manager wiring), NOT by re-reporting per root.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors the value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
|
||||
/// carries the <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays, with each alarm
|
||||
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so
|
||||
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null
|
||||
/// Mirrors the v3 value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
|
||||
/// carries the v3 raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) with the alarm
|
||||
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
|
||||
/// <c>EquipmentTagAlarmInfo</c>. The OPC UA sink + dependency mux are injected as TestProbes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
@@ -49,25 +47,26 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
||||
private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's <c>FullName</c> lands on
|
||||
/// the condition's folder-scoped NodeId (here <c>eq-1/temp_hi</c>) as an
|
||||
// The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "Modbus" / Device "dev1" / Tag.
|
||||
private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";
|
||||
|
||||
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's RawPath (the v3 wire-ref)
|
||||
/// lands on the RAW condition NodeId (== the RawPath, Raw realm) as an
|
||||
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
|
||||
/// production-shaped <c>SourceNodeId</c> (the bare owning object, distinct from <c>ConditionId</c>) so the
|
||||
/// lookup is proven to key on <c>ConditionId</c>, not <c>SourceNodeId</c>.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active()
|
||||
/// distinct <c>SourceNodeId</c> so the lookup is proven to key on <c>ConditionId</c>, not
|
||||
/// <c>SourceNodeId</c>.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_raise_routes_to_raw_condition_NodeId_active()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
// One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi".
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
||||
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
|
||||
ConditionId: AlarmRawPath, // the v3 wire-ref RawPath = the lookup key
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -75,7 +74,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
||||
update.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
update.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
update.State.Active.ShouldBeTrue();
|
||||
update.State.Acknowledged.ShouldBeFalse();
|
||||
update.TimestampUtc.ShouldBe(Ts);
|
||||
@@ -87,35 +87,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
public void Unknown_alarm_ref_produces_no_AlarmStateUpdate()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp", // owning object exists, but the condition ref below is unmapped
|
||||
ConditionId: "NoSuch.HiHi", // dotted ref not in the alarm map ⇒ drop
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Plant/Modbus/dev1/no_such", // unmapped RawPath ⇒ drop
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "nope",
|
||||
Severity: AlarmSeverity.Low,
|
||||
SourceTimestampUtc: Ts,
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
// No alarm-condition NodeId for ("drv-1","NoSuch.Alarm") → nothing reaches the sink.
|
||||
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
/// <summary>Primary (default/unset role) fan-out (Phase B WS-5): a native alarm RAISE on a known ref
|
||||
/// publishes exactly one <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with
|
||||
/// <c>AlarmId</c> = the folder-scoped condition NodeId, alongside the (ungated) OPC UA condition
|
||||
/// update. No <see cref="RedundancyStateChanged"/> is sent, so the cached role is unknown ⇒ emit.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <summary>Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one
|
||||
/// <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with <c>AlarmId</c> = the raw
|
||||
/// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
@@ -124,8 +120,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
||||
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -134,38 +130,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
// The OPC UA condition update is UNGATED — it must arrive.
|
||||
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
||||
update.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
|
||||
// Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic.
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
evt.AlarmId.ShouldBe("eq-1/temp_hi"); // the folder-scoped condition NodeId
|
||||
evt.EquipmentPath.ShouldBe("eq-1"); // from the alarm-bearing tag's EquipmentId
|
||||
evt.AlarmName.ShouldBe("temp_hi"); // from the tag's Name
|
||||
evt.AlarmId.ShouldBe(AlarmRawPath); // the raw condition NodeId (v3: ConditionId == RawPath)
|
||||
evt.EquipmentPath.ShouldBe(AlarmRawPath); // v3: the alarm meta keys the display off the RawPath
|
||||
evt.AlarmName.ShouldBe("temp_hi"); // from the raw tag's Name
|
||||
evt.TransitionKind.ShouldBe("Activated"); // native Kind → canonical EmissionKind vocabulary (Raise → Activated)
|
||||
evt.AlarmTypeName.ShouldBe("OffNormalAlarm"); // the tag's alarm AlarmType
|
||||
evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700
|
||||
evt.Message.ShouldBe("temperature high");
|
||||
evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user)
|
||||
// This tag's TagConfig.alarm carries no historizeToAveva key ⇒ null ⇒ the HistorianAdapterActor
|
||||
// gate (historizeToAveva is not false) still historizes (default-on). Only an explicit false
|
||||
// suppresses the durable AVEVA row — see Native_alarm_historizeToAveva_false_threads_through.
|
||||
evt.HistorizeToAveva.ShouldBeNull();
|
||||
evt.HistorizeToAveva.ShouldBeNull(); // absent historizeToAveva key ⇒ null ⇒ default-on
|
||||
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one
|
||||
}
|
||||
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-out (Task 3): a tag whose <c>TagConfig.alarm</c> carries
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-out: a tag whose <c>TagConfig.alarm</c> carries
|
||||
/// <c>historizeToAveva: false</c> publishes its <see cref="AlarmTransitionEvent"/> with
|
||||
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate
|
||||
/// (<c>historizeToAveva is not false</c>) suppresses the durable AVEVA write — the same opt-out the
|
||||
/// scripted-alarm plan flag drives. The live <c>/alerts</c> fan-out is unaffected (the transition still
|
||||
/// publishes; only the durable row is gated downstream).</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate suppresses the
|
||||
/// durable AVEVA write. The live <c>/alerts</c> fan-out is unaffected.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_historizeToAveva_false_threads_through()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
|
||||
historizeToAveva: false);
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: false);
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
@@ -175,7 +164,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Temp.HiHi",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -183,21 +172,17 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
Kind: AlarmTransitionKind.Raise)));
|
||||
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
evt.AlarmId.ShouldBe("eq-1/temp_hi");
|
||||
// The explicit opt-out rides onto the transition ⇒ the historian gate suppresses the durable row.
|
||||
evt.AlarmId.ShouldBe(AlarmRawPath);
|
||||
evt.HistorizeToAveva.ShouldBe(false);
|
||||
}
|
||||
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit <c>historizeToAveva: true</c>
|
||||
/// rides through as <c>true</c> (distinct from the absent ⇒ null default-on case) so an operator who
|
||||
/// deliberately opts in is recorded as such on the transition.</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <summary>Native-alarm HistorizeToAveva opt-IN: an explicit <c>historizeToAveva: true</c> rides through as
|
||||
/// <c>true</c> (distinct from the absent ⇒ null default-on case).</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_historizeToAveva_true_threads_through()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
|
||||
historizeToAveva: true);
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: true);
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
@@ -207,7 +192,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Temp.HiHi",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -218,16 +203,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
evt.HistorizeToAveva.ShouldBe(true);
|
||||
}
|
||||
|
||||
/// <summary>Secondary suppression (Phase B WS-5): when the cached local role is Secondary the host
|
||||
/// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm
|
||||
/// for failover) but MUST NOT publish the cluster-wide <c>alerts</c> transition (the Primary publishes
|
||||
/// the single fleet-wide copy).</summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <summary>Secondary suppression: when the cached local role is Secondary the host MUST still write the
|
||||
/// local OPC UA condition node (ungated — keeps the standby warm) but MUST NOT publish the cluster-wide
|
||||
/// <c>alerts</c> transition (the Primary publishes the single fleet-wide copy).</summary>
|
||||
[Fact]
|
||||
public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
@@ -239,8 +222,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
|
||||
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "temperature high",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -249,7 +232,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
|
||||
// The OPC UA condition update is UNGATED — it must still arrive on the secondary.
|
||||
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
|
||||
update.AlarmNodeId.ShouldBe(AlarmRawPath);
|
||||
update.State.Active.ShouldBeTrue();
|
||||
|
||||
// The cluster-wide alerts publish is gated off on the secondary.
|
||||
@@ -257,27 +240,23 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
}
|
||||
|
||||
/// <summary>A native alarm whose <c>AlarmEventArgs.OperatorComment</c> is set flows through
|
||||
/// <c>DriverHostActor.ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>:
|
||||
/// <c>Comment</c> carries the operator string and <c>User</c> is <c>"device"</c> (a non-null comment
|
||||
/// signals the upstream alarm system provided an operator origin, but without a specific user identity).
|
||||
/// </summary>
|
||||
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
|
||||
/// <c>ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>: <c>Comment</c> carries
|
||||
/// the operator string and <c>User</c> is <c>"device"</c>.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_operator_comment_flows_to_transition_event()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
|
||||
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
|
||||
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
|
||||
|
||||
var alerts = CreateTestProbe();
|
||||
SubscribeToAlerts(alerts);
|
||||
|
||||
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
|
||||
|
||||
// Send an alarm whose OperatorComment is set — simulates an upstream acknowledge-with-comment.
|
||||
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
|
||||
new StubAlarmHandle(),
|
||||
SourceNodeId: "Temp",
|
||||
ConditionId: "Temp.HiHi",
|
||||
ConditionId: AlarmRawPath,
|
||||
AlarmType: "OffNormalAlarm",
|
||||
Message: "investigating",
|
||||
Severity: AlarmSeverity.High,
|
||||
@@ -285,18 +264,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
Kind: AlarmTransitionKind.Acknowledge,
|
||||
OperatorComment: "investigating")));
|
||||
|
||||
// OPC UA condition update is ungated — drain it.
|
||||
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// The published AlarmTransitionEvent must carry the comment + the "device" user marker.
|
||||
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
|
||||
evt.Comment.ShouldBe("investigating");
|
||||
evt.User.ShouldBe("device");
|
||||
}
|
||||
|
||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.
|
||||
/// The Subscribe is sent FROM the probe so the SubscribeAck returns to it. Mirrors the
|
||||
/// ScriptedAlarmHostActor test harness.</summary>
|
||||
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
|
||||
private void SubscribeToAlerts(TestProbe probe)
|
||||
{
|
||||
DistributedPubSub.Get(Sys).Mediator.Tell(
|
||||
@@ -304,9 +279,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own
|
||||
/// node (<see cref="TestNode"/>, which equals the host's <c>_localNode</c>) with <paramref name="role"/>
|
||||
/// so the alerts-publish gate observes the local role.</summary>
|
||||
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own node
|
||||
/// (<see cref="TestNode"/>) with <paramref name="role"/> so the alerts-publish gate observes it.</summary>
|
||||
private static void TellRedundancyRole(IActorRef host, RedundancyRole role)
|
||||
{
|
||||
host.Tell(new RedundancyStateChanged(
|
||||
@@ -322,10 +296,10 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
CorrelationId.NewId()));
|
||||
}
|
||||
|
||||
/// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied
|
||||
/// ACK so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the
|
||||
/// test publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
||||
private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(
|
||||
/// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied ACK
|
||||
/// so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the test
|
||||
/// publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
|
||||
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
|
||||
{
|
||||
var coordinator = CreateTestProbe();
|
||||
@@ -352,47 +326,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
|
||||
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object
|
||||
/// (<c>alarmType</c> + <c>severity</c>) so <c>DeploymentArtifact.ExtractTagAlarm</c> projects a
|
||||
/// non-null <c>EquipmentTagAlarmInfo</c> (here <c>OffNormalAlarm</c> / 700) — making the tag a
|
||||
/// condition rather than a value variable.
|
||||
/// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
|
||||
/// DriverInstance(RawFolderId, Name "Modbus") → Device "dev1" → Tag) with the tag's <c>TagConfig</c>
|
||||
/// carrying an <c>alarm</c> object so the composer projects a non-null <c>EquipmentTagAlarmInfo</c>
|
||||
/// (<c>OffNormalAlarm</c> / 700) — making the raw tag a Part 9 condition at its RawPath rather than a
|
||||
/// value variable. Enums serialize numerically. No UNS reference is needed for these routing/emit tests
|
||||
/// (the SDK-notifier fan-out to referencing equipment is covered by the node-manager tests).
|
||||
/// </summary>
|
||||
private static DeploymentId SeedDeploymentWithAlarmTag(
|
||||
private static DeploymentId SeedV3AlarmDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
string Equip, string Driver, string FullName, string? Folder, string Name,
|
||||
bool? historizeToAveva = null)
|
||||
string Driver, string Tag, bool? historizeToAveva = null)
|
||||
{
|
||||
// historizeToAveva absent (null) ⇒ omit the key entirely so the absent ⇒ historize default path is
|
||||
// exercised; a concrete true/false writes the bool into the alarm object so the native path threads it.
|
||||
// historizeToAveva absent (null) ⇒ omit the key so the absent ⇒ historize default path is exercised;
|
||||
// a concrete true/false writes the bool into the alarm object so the native path threads it.
|
||||
object alarm = historizeToAveva is { } h
|
||||
? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h }
|
||||
: new { alarmType = "OffNormalAlarm", severity = 700 };
|
||||
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
Namespaces = new[]
|
||||
{
|
||||
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
|
||||
},
|
||||
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new { DriverInstanceId = Driver, NamespaceId = "ns-eq" },
|
||||
new { DriverInstanceId = Driver, RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
|
||||
},
|
||||
Devices = new[]
|
||||
{
|
||||
new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
|
||||
},
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "tag-0",
|
||||
EquipmentId = Equip,
|
||||
DriverInstanceId = Driver,
|
||||
Name,
|
||||
FolderPath = Folder,
|
||||
DeviceId = $"{Driver}:dev1",
|
||||
TagGroupId = (string?)null,
|
||||
Name = Tag,
|
||||
DataType = "Boolean",
|
||||
TagConfig = JsonSerializer.Serialize(new
|
||||
{
|
||||
FullName,
|
||||
alarm,
|
||||
}),
|
||||
AccessLevel = 0, // TagAccessLevel.Read
|
||||
TagConfig = JsonSerializer.Serialize(new { alarm }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+1
@@ -224,6 +224,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
|
||||
public void RebuildAddressSpace() { /* recorded via span */ }
|
||||
/// <summary>Announces a NodeAdded model-change (stub implementation).</summary>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -125,6 +125,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
@@ -138,6 +139,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
public void RebuildAddressSpace() { }
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
|
||||
@@ -473,6 +473,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}");
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
/// <summary>Records a surgical in-place tag-attribute update (always succeeds in this recording sink).</summary>
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
|
||||
|
||||
@@ -647,6 +647,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
|
||||
/// <summary>Records a NodeAdded model-change announcement.</summary>
|
||||
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user