feat(dcl): populate obtainable NativeAlarmTransition fields from OPC UA and MxGateway (#27, M2.13)

OPC UA (RealOpcUaClient):
- Append 5 new SelectClauses at indices 13–17 (never renumber 0–12):
  - 13: AlarmConditionType/ActiveState/TransitionTime → OriginalRaiseTime
  - 14–17: LimitAlarmType HighHighLimit/HighLimit/LowLimit/LowLowLimit → LimitValue
- New OpcUaAlarmMapper.PickLimitValue helper: first non-null in HiHi→Hi→Lo→LoLo
  priority order, InvariantCulture-formatted; empty string for non-limit alarm types.
- HandleAlarmEvent reads new indices with fields.Count > N guards; hard minimum (6)
  unchanged so base ConditionType events still process without the limit fields.
- Document unavailable-by-protocol fields (Category, Description, OperatorUser,
  CurrentValue) inline in BuildAlarmEventFilter and HandleAlarmEvent.

MxGateway (MxGatewayAlarmMapper):
- MapTransition: CurrentValue and LimitValue now populated via MxValueToString
  (uses MxValueExtensions.ToClrValue + InvariantCulture) from OnAlarmTransitionEvent
  proto fields current_value/limit_value.
- MapSnapshot: same — populated from ActiveAlarmSnapshot.current_value/limit_value.
- MxValueToString helper (internal): null-safe MxValue → string conversion.

Tests (17 new, 40 total pass):
- OpcUaAlarmMapperTests: PickLimitValue priority, InvariantCulture, all-null case.
- MxGatewayAlarmMapperTests: CurrentValue/LimitValue populate from double/string
  MxValue; absent fields yield empty strings.
- RealOpcUaClientAlarmFilterTests: index alignment assertions (count=18, per-index
  TypeDefinitionId+BrowsePath), regression guard on existing indices 0–12.
This commit is contained in:
Joseph Doherty
2026-06-16 06:37:19 -04:00
parent e2b31a9fd2
commit 722b8663c1
8 changed files with 338 additions and 9 deletions
@@ -1,3 +1,5 @@
using System.Globalization;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ProtoConditionState = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState;
using ProtoTransitionKind = ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind;
@@ -67,6 +69,19 @@ public static class MxGatewayAlarmMapper
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
}
/// <summary>
/// Converts an <see cref="MxValue"/> union to a display-only string using
/// <see cref="MxValueExtensions.ToClrValue"/> and invariant culture formatting,
/// so numeric values always use '.' as the decimal separator. Null or unset
/// values produce an empty string.
/// </summary>
internal static string MxValueToString(MxValue? mxVal)
{
if (mxVal is null) return "";
var clr = mxVal.ToClrValue();
return clr is null ? "" : Convert.ToString(clr, CultureInfo.InvariantCulture) ?? "";
}
/// <summary>Maps a live <see cref="OnAlarmTransitionEvent"/> to a transition.</summary>
/// <param name="body">The gateway alarm transition event proto message to map.</param>
/// <returns>The protocol-neutral <see cref="NativeAlarmTransition"/>.</returns>
@@ -83,8 +98,8 @@ public static class MxGatewayAlarmMapper
OperatorComment: body.OperatorComment,
OriginalRaiseTime: body.OriginalRaiseTimestamp?.ToDateTimeOffset(),
TransitionTime: body.TransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
CurrentValue: "",
LimitValue: "");
CurrentValue: MxValueToString(body.CurrentValue),
LimitValue: MxValueToString(body.LimitValue));
/// <summary>The end-of-snapshot sentinel transition (no condition payload).</summary>
/// <returns>A <see cref="NativeAlarmTransition"/> with <c>AlarmTransitionKind.SnapshotComplete</c>.</returns>
@@ -109,6 +124,6 @@ public static class MxGatewayAlarmMapper
OperatorComment: snapshot.OperatorComment,
OriginalRaiseTime: snapshot.OriginalRaiseTimestamp?.ToDateTimeOffset(),
TransitionTime: snapshot.LastTransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow,
CurrentValue: "",
LimitValue: "");
CurrentValue: MxValueToString(snapshot.CurrentValue),
LimitValue: MxValueToString(snapshot.LimitValue));
}
@@ -65,4 +65,40 @@ public static class OpcUaAlarmMapper
null or "Unshelved" => AlarmShelveState.Unshelved,
_ => AlarmShelveState.OneShotShelved
};
/// <summary>
/// Picks a representative display-only limit value from the four standard
/// <c>LimitAlarmType</c> set-point fields (HighHighLimit, HighLimit, LowLimit,
/// LowLowLimit) returned by the OPC UA event SelectClause.
///
/// <para>
/// The fields are absent (null raw value) on non-limit alarm types (discrete,
/// off-normal, etc.). When present, the first non-null value is returned in
/// priority order: HighHigh → High → Low → LowLow. The caller may use
/// <c>AlarmTypeName</c> or <c>ConditionName</c> to determine which specific
/// limit is active; this method intentionally returns the coarsest useful value
/// for the common single-limit case without requiring callers to understand the
/// OPC UA limit hierarchy.
/// </para>
/// </summary>
/// <param name="highHighRaw">Raw HighHighLimit field value (null when absent).</param>
/// <param name="highRaw">Raw HighLimit field value (null when absent).</param>
/// <param name="lowRaw">Raw LowLimit field value (null when absent).</param>
/// <param name="lowLowRaw">Raw LowLowLimit field value (null when absent).</param>
/// <returns>
/// A formatted string representation of the first non-null limit value, or an
/// empty string when all four fields are absent (non-limit alarm type).
/// </returns>
public static string PickLimitValue(object? highHighRaw, object? highRaw, object? lowRaw, object? lowLowRaw)
{
// Standard OPC UA LimitAlarmType limit values are numeric (Double/Float/Int).
// Convert with InvariantCulture so the decimal separator is always '.' regardless
// of the server's locale.
foreach (var raw in new[] { highHighRaw, highRaw, lowRaw, lowLowRaw })
{
if (raw is not null)
return Convert.ToString(raw, System.Globalization.CultureInfo.InvariantCulture) ?? "";
}
return "";
}
}
@@ -393,6 +393,34 @@ public class RealOpcUaClient : IOpcUaClient
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "ConditionName")); // 11
filter.SelectClauses.Add(SelectField(ObjectTypeIds.ConditionType, "Comment")); // 12
// APPENDED fields (indices 13+): optional — only present on specific derived types.
// Guard all reads with fields.Count > N so base-ConditionType events still process.
// 13: AlarmConditionType/ActiveState/TransitionTime — the UTC instant the active-state
// last flipped to TRUE. Mapped to OriginalRaiseTime; absent on non-AlarmCondition
// events (ConditionType base events rarely carry it).
filter.SelectClauses.Add(SelectField(ObjectTypeIds.AlarmConditionType, "ActiveState", "TransitionTime")); // 13
// 1417: LimitAlarmType limit thresholds — configuration-time set-points exposed as
// event fields by LimitAlarmType and all its subtypes (Exclusive/NonExclusive
// Level/Deviation/RateOfChange). Absent on non-limit alarm types (e.g. discrete,
// off-normal) — guarded by fields.Count > N below.
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighHighLimit")); // 14
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "HighLimit")); // 15
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLimit")); // 16
filter.SelectClauses.Add(SelectField(ObjectTypeIds.LimitAlarmType, "LowLowLimit")); // 17
// UNAVAILABLE via standard OPC UA A&C event fields (documented here so future
// maintainers know these were considered, not overlooked):
// Category — not a standard event field; server-specific extensions only.
// Description — not a per-event text field; the OPC UA Description attribute is a
// static node property, not carried in event notifications.
// OperatorUser — not available on the standard ConditionRefresh replay stream;
// present on Acknowledge/Confirm method call results, but those do
// not flow through the monitored-item subscription.
// CurrentValue — the live process variable value is NOT a standard A&C event field;
// it would require a separate data subscription on the source node.
ApplyServerSideTypeWhereClause(filter, conditionFilter);
return filter;
}
@@ -507,6 +535,23 @@ public class RealOpcUaClient : IOpcUaClient
var shelve = OpcUaAlarmMapper.MapShelve(fields.Count > 10 ? (fields[10].Value as LocalizedText)?.Text : null);
var comment = fields.Count > 12 ? (fields[12].Value as LocalizedText)?.Text ?? "" : "";
// Index 13: ActiveState/TransitionTime → OriginalRaiseTime (when active-state last
// transitioned to TRUE). Absent on non-AlarmCondition events → guard + null fallback.
DateTimeOffset? originalRaiseTime = null;
if (fields.Count > 13 && fields[13].Value is DateTime activeTransitionTime)
originalRaiseTime = new DateTimeOffset(activeTransitionTime, TimeSpan.Zero);
// Indices 1417: LimitAlarmType set-point thresholds (HighHighLimit/HighLimit/
// LowLimit/LowLowLimit). Absent on non-limit alarm types → null when missing.
// Pick the first non-null value in priority order (HiHi > Hi > Lo > LoLo) as a
// display-only representative limit; the caller is responsible for interpreting
// which limit is active using AlarmTypeName or ConditionName.
var limitValue = OpcUaAlarmMapper.PickLimitValue(
fields.Count > 14 ? fields[14].Value : null,
fields.Count > 15 ? fields[15].Value : null,
fields.Count > 16 ? fields[16].Value : null,
fields.Count > 17 ? fields[17].Value : null);
var inRefresh = _alarmInRefresh.GetValueOrDefault(handle);
var lastState = _alarmLastState.GetValueOrDefault(handle);
var (prevActive, prevAcked) = lastState != null && lastState.TryGetValue(sourceRef, out var prev) ? prev : (false, true);
@@ -524,15 +569,18 @@ public class RealOpcUaClient : IOpcUaClient
AlarmTypeName: ResolveAlarmTypeName(eventType),
Kind: kind,
Condition: OpcUaAlarmMapper.BuildCondition(active, acked, confirmed, shelve, suppressed, severity),
// UNAVAILABLE via standard OPC UA A&C event fields — see BuildAlarmEventFilter comments.
Category: "",
Description: "",
Message: message,
// UNAVAILABLE: OperatorUser not on refresh stream — see BuildAlarmEventFilter comments.
OperatorUser: "",
OperatorComment: comment,
OriginalRaiseTime: null,
OriginalRaiseTime: originalRaiseTime,
TransitionTime: time,
// UNAVAILABLE: CurrentValue not a standard A&C event field — see BuildAlarmEventFilter.
CurrentValue: "",
LimitValue: ""));
LimitValue: limitValue));
}
private static NativeAlarmTransition SnapshotComplete() => new(