dd4f1c2aa3
AlarmConditionState.Severity mixed two conventions: OPC UA rows carried Part 9 Severity (higher = more severe) while MxGateway native alarms and computed alarms carried a raw AVEVA-style priority (1 = most urgent). Every consumer ranks higher as worse, so Worst Severity, the severity filter/sort and the site event-log Error threshold picked the LEAST urgent MxGateway/computed alarms. - AlarmPriorityBands: the OtOpcUa GalaxyAlarmPriority table (lmxopcua#887, b61f3e05), value for value: 1-250 -> 1000, 251-500 -> 750, 501-750 -> 500, 751-999 -> 250; <= 0 clamps to Critical, > 999 to Low. - MxGatewayAlarmMapper bands the gateway severity (= AVEVA priority); AlarmConditionStateFactory.ForComputed bands PriorityLevel. OPC UA unchanged. - AlarmConditionState gains trailing Priority (int?): the raw source number, exactly the pre-#129 Severity. AlarmStateChanged.Priority and the wire AlarmStateUpdate.priority keep that meaning (no proto change); central derives Severity by kind (SeverityFromPriority), so an older site decodes correctly. - ScriptAlarm gains trailing Priority. The native MES 900-999 band and the MES API Severity/MinSeverity/MaxSeverity key off Priority; rig seed scripts moved from a.Severity to a.Priority, so MES output is unchanged. - AlarmActor raise logs Error when the banded severity >= 700 (same threshold as NativeAlarmActor): priority 1-500 Error, 501+ Warning (was raw >= 700). - NativeAlarmActor upgrades pre-#129 native_alarm_state rows on rehydrate. - Debug snapshot decode restores Condition.Priority from the row's priority. - Docs: Commons, DCL, SiteRuntime, Communication, CentralUI, InboundAPI, HighLevelReqs, MES alarm-status plan, CLAUDE.md. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
208 lines
12 KiB
C#
208 lines
12 KiB
C#
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;
|
||
// Alias the Commons alarm types so their simple names bind here, unambiguous
|
||
// against the colliding gateway proto enums above.
|
||
using NativeAlarmTransition = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.NativeAlarmTransition;
|
||
using AlarmConditionState = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.AlarmConditionState;
|
||
using AlarmPriorityBands = ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms.AlarmPriorityBands;
|
||
using AlarmTransitionKind = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTransitionKind;
|
||
using AlarmShelveState = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmShelveState;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||
|
||
/// <summary>
|
||
/// Pure mapping from MxAccess Gateway alarm-feed proto messages to the
|
||
/// protocol-neutral <see cref="NativeAlarmTransition"/> shape. The gateway proto
|
||
/// enums (AlarmConditionState / AlarmTransitionKind) collide with the Commons
|
||
/// alarm enums, so they are aliased here. Unit-tested without a live gateway.
|
||
/// </summary>
|
||
public static class MxGatewayAlarmMapper
|
||
{
|
||
/// <summary>
|
||
/// The raw priority kept on <see cref="AlarmConditionState.Priority"/>: the gateway's
|
||
/// <c>severity</c> field — which mxaccessgw fills from the AVEVA alarm <b>priority</b>
|
||
/// (1 = most urgent … 999 = least) — clamped to 0–1000, i.e. exactly the value that
|
||
/// <see cref="AlarmConditionState.Severity"/> carried before Gitea #129. The native MES
|
||
/// 900–999 band keys off this value, so it must keep selecting the same alarms.
|
||
/// </summary>
|
||
/// <param name="rawPriority">The gateway's raw <c>severity</c> field (the AVEVA priority).</param>
|
||
/// <returns>The priority clamped to the range [0, 1000].</returns>
|
||
public static int NormalizePriority(int rawPriority) => Math.Clamp(rawPriority, 0, 1000);
|
||
|
||
/// <summary>
|
||
/// Maps the gateway's raw <c>severity</c> field (the AVEVA priority, lower = more urgent) onto the
|
||
/// unified <see cref="AlarmConditionState.Severity"/> (higher = more urgent) through
|
||
/// <see cref="AlarmPriorityBands"/> — the same table OtOpcUa applies to the same alarms
|
||
/// (lmxopcua#887), so the MxGateway and OPC UA paths agree (Gitea #129).
|
||
/// </summary>
|
||
/// <param name="rawPriority">The gateway's raw <c>severity</c> field (the AVEVA priority).</param>
|
||
/// <returns>The banded severity: 1000, 750, 500 or 250.</returns>
|
||
public static int MapSeverity(int rawPriority) => AlarmPriorityBands.ToSeverity(rawPriority);
|
||
|
||
/// <summary>Builds the condition for one gateway alarm: banded Severity + verbatim Priority.</summary>
|
||
private static AlarmConditionState BuildCondition(bool active, bool acked, int rawPriority) =>
|
||
new(active, acked, Confirmed: null, Shelve: AlarmShelveState.Unshelved, Suppressed: false,
|
||
Severity: MapSeverity(rawPriority), Priority: NormalizePriority(rawPriority));
|
||
|
||
/// <summary>Maps a gateway condition-state + severity to the unified condition.</summary>
|
||
/// <param name="state">Gateway proto condition state to map.</param>
|
||
/// <param name="severity">The gateway's raw <c>severity</c> field (the AVEVA priority); banded onto Severity, kept on Priority.</param>
|
||
/// <returns>The protocol-neutral <see cref="AlarmConditionState"/> equivalent.</returns>
|
||
public static AlarmConditionState MapConditionState(ProtoConditionState state, int severity)
|
||
{
|
||
var (active, acked) = state switch
|
||
{
|
||
ProtoConditionState.Active => (true, false),
|
||
ProtoConditionState.ActiveAcked => (true, true),
|
||
ProtoConditionState.Inactive => (false, true),
|
||
_ => (false, true)
|
||
};
|
||
return BuildCondition(active, acked, severity);
|
||
}
|
||
|
||
/// <summary>Maps a gateway transition kind to the unified transition kind.</summary>
|
||
/// <param name="kind">Gateway proto transition kind to map.</param>
|
||
/// <returns>The protocol-neutral <see cref="AlarmTransitionKind"/> equivalent.</returns>
|
||
public static AlarmTransitionKind MapKind(ProtoTransitionKind kind) => kind switch
|
||
{
|
||
ProtoTransitionKind.Raise => AlarmTransitionKind.Raise,
|
||
ProtoTransitionKind.Acknowledge => AlarmTransitionKind.Acknowledge,
|
||
ProtoTransitionKind.Clear => AlarmTransitionKind.Clear,
|
||
ProtoTransitionKind.Retrigger => AlarmTransitionKind.Retrigger,
|
||
_ => AlarmTransitionKind.StateChange
|
||
};
|
||
|
||
/// <summary>Derives the mirrored condition from a transition kind + severity.</summary>
|
||
private static AlarmConditionState ConditionFromKind(ProtoTransitionKind kind, int severity)
|
||
{
|
||
var (active, acked) = kind switch
|
||
{
|
||
ProtoTransitionKind.Raise => (true, false),
|
||
ProtoTransitionKind.Acknowledge => (true, true),
|
||
ProtoTransitionKind.Retrigger => (true, false),
|
||
ProtoTransitionKind.Clear => (false, true),
|
||
_ => (false, true)
|
||
};
|
||
return BuildCondition(active, acked, severity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Derives the ack timestamp mirrored onto <c>NativeAlarmTransition.AckTime</c>
|
||
/// (MES alarm-status API §6.4).
|
||
///
|
||
/// <para>
|
||
/// Unlike OPC UA A&C, the MxAccess Gateway alarm feed carries NO dedicated ack
|
||
/// timestamp — an acknowledgement arrives as an <c>ACTIVE_ACKED</c> condition state on a
|
||
/// transition whose only time is <paramref name="transitionTime"/> (the gateway's
|
||
/// transition timestamp, or the DCL's own receipt time when the gateway omits it). That
|
||
/// observation time is what gets stamped: accurate to when the system saw the ack, never
|
||
/// fabricated, but not necessarily the operator's click.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Returns <c>null</c> unless the condition is active AND acknowledged — matching
|
||
/// <c>OpcUaAlarmMapper.DeriveAckTime</c>, so both native protocols agree on "null while
|
||
/// unacked, cleared on re-raise". This matters especially here because the gateway maps
|
||
/// INACTIVE to <c>acked = true</c>: without the active check, every return-to-normal
|
||
/// would report an ack it never observed.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <param name="condition">The mirrored condition state for the transition.</param>
|
||
/// <param name="transitionTime">The transition's timestamp — the DCL's observation instant for the ack.</param>
|
||
/// <returns>The observed ack timestamp, or <c>null</c> when the condition is not an outstanding acknowledged alarm.</returns>
|
||
public static DateTimeOffset? DeriveAckTime(AlarmConditionState condition, DateTimeOffset transitionTime) =>
|
||
condition is { Active: true, Acknowledged: true } ? transitionTime : null;
|
||
|
||
/// <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>
|
||
/// <param name="mxVal">The gateway value union to convert, or null.</param>
|
||
/// <returns>The value formatted as an invariant-culture string, or an empty string if null or unset.</returns>
|
||
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>
|
||
public static NativeAlarmTransition MapTransition(OnAlarmTransitionEvent body)
|
||
{
|
||
var condition = ConditionFromKind(body.TransitionKind, body.Severity);
|
||
var transitionTime = body.TransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow;
|
||
|
||
return new NativeAlarmTransition(
|
||
// Identify the condition by the object-relative reference (e.g.
|
||
// "Z28061.HeartbeatTimeoutAlarm") rather than the gateway's full provider
|
||
// reference ("Galaxy!<area>.<object>.<alarm>"). The area is preserved in
|
||
// Category; the object reference is globally unique within the galaxy and
|
||
// is the form operators expect. Falls back to the full reference only if
|
||
// the gateway omits the object reference.
|
||
SourceReference: string.IsNullOrEmpty(body.SourceObjectReference)
|
||
? body.AlarmFullReference : body.SourceObjectReference,
|
||
SourceObjectReference: body.SourceObjectReference,
|
||
AlarmTypeName: body.AlarmTypeName,
|
||
Kind: MapKind(body.TransitionKind),
|
||
Condition: condition,
|
||
Category: body.Category,
|
||
Description: body.Description,
|
||
Message: body.Description,
|
||
OperatorUser: body.OperatorUser,
|
||
OperatorComment: body.OperatorComment,
|
||
OriginalRaiseTime: body.OriginalRaiseTimestamp?.ToDateTimeOffset(),
|
||
TransitionTime: transitionTime,
|
||
CurrentValue: MxValueToString(body.CurrentValue),
|
||
LimitValue: MxValueToString(body.LimitValue),
|
||
// MES alarm-status API §6.4: the gateway supplies no ack timestamp, so an ack
|
||
// is stamped with the transition time it was observed at.
|
||
AckTime: DeriveAckTime(condition, transitionTime));
|
||
}
|
||
|
||
/// <summary>The end-of-snapshot sentinel transition (no condition payload).</summary>
|
||
/// <returns>A <see cref="NativeAlarmTransition"/> with <c>AlarmTransitionKind.SnapshotComplete</c>.</returns>
|
||
public static NativeAlarmTransition SnapshotComplete() => new(
|
||
"", "", "", AlarmTransitionKind.SnapshotComplete,
|
||
new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
|
||
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
|
||
|
||
/// <summary>Maps one initial-snapshot <see cref="ActiveAlarmSnapshot"/> entry to a Snapshot transition.</summary>
|
||
/// <param name="snapshot">The active alarm snapshot proto message to map.</param>
|
||
/// <returns>A <see cref="NativeAlarmTransition"/> with <c>AlarmTransitionKind.Snapshot</c>.</returns>
|
||
public static NativeAlarmTransition MapSnapshot(ActiveAlarmSnapshot snapshot)
|
||
{
|
||
var condition = MapConditionState(snapshot.CurrentState, snapshot.Severity);
|
||
var transitionTime = snapshot.LastTransitionTimestamp?.ToDateTimeOffset() ?? DateTimeOffset.UtcNow;
|
||
|
||
return new NativeAlarmTransition(
|
||
// See MapTransition: identify by the object-relative reference, not the
|
||
// full "Galaxy!<area>.<object>.<alarm>" provider reference.
|
||
SourceReference: string.IsNullOrEmpty(snapshot.SourceObjectReference)
|
||
? snapshot.AlarmFullReference : snapshot.SourceObjectReference,
|
||
SourceObjectReference: snapshot.SourceObjectReference,
|
||
AlarmTypeName: snapshot.AlarmTypeName,
|
||
Kind: AlarmTransitionKind.Snapshot,
|
||
Condition: condition,
|
||
Category: snapshot.Category,
|
||
Description: snapshot.Description,
|
||
Message: snapshot.Description,
|
||
OperatorUser: snapshot.OperatorUser,
|
||
OperatorComment: snapshot.OperatorComment,
|
||
OriginalRaiseTime: snapshot.OriginalRaiseTimestamp?.ToDateTimeOffset(),
|
||
TransitionTime: transitionTime,
|
||
CurrentValue: MxValueToString(snapshot.CurrentValue),
|
||
LimitValue: MxValueToString(snapshot.LimitValue),
|
||
// MES alarm-status API §6.4: an ACTIVE_ACKED snapshot entry reports the last
|
||
// transition time as its ack instant — the best the gateway feed supplies. A
|
||
// (re)subscribe snapshot therefore restores an ack time rather than losing it.
|
||
AckTime: DeriveAckTime(condition, transitionTime));
|
||
}
|
||
}
|