Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayAlarmMapper.cs
T
Joseph Doherty 01bcca992c feat(alarms): thread an additive AckTime through the native-alarm mirror
MES alarm-status API §6.4 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 task 1). MES needs a real AckDT for a triggered alarm, and the mirror
carried acked-vs-unacked but never WHEN. AckTime now rides the whole path:
DCL transition -> AlarmStateChanged -> gRPC AlarmStateUpdate -> site SQLite.

Stamping rule, identical on both protocols: non-null ONLY while the condition
is active AND acknowledged. That single predicate yields all three required
behaviours -- null while unacked, cleared on re-raise (a re-raise arrives
unacknowledged), and no phantom ack on a return-to-normal. The last one is
load-bearing for MxGateway, which maps INACTIVE to Acknowledged = true; without
the active check every clear would claim an ack the system never observed.

Provenance is honest, never fabricated:
  - OPC UA A&C supplies a TRUE ack instant, so we now select it:
    AcknowledgeableConditionType/AckedState/TransitionTime at SelectClause
    index 18, APPENDED so the positional reads at 0-17 keep their meaning.
    Servers that omit the field fall back to the event's own Time.
  - MxAccess Gateway supplies none, so the ack transition's own timestamp is
    used -- accurate to when the system SAW the ack. An ACTIVE_ACKED
    re-subscribe snapshot restores one from LastTransitionTimestamp rather
    than dropping it.
The decision lives in pure mappers (Opc/Mx AlarmMapper.DeriveAckTime), so it is
unit-tested with no live server or gateway.

Additive-only throughout: init-only property on AlarmStateChanged, trailing
optional positional on NativeAlarmTransition (all 14-arg call sites untouched),
proto field 24 (never reusing a number) regenerated via docker/regen-proto.sh
sitestream with the csproj diff verified empty.

Persistence rides native_alarm_state's existing metadata_json blob rather than
a new column -- deliberately. That table is RegisterReplicated in
SiteLocalDbSetup and LocalDb builds its CDC triggers from the column list at
registration time, so an additive JSON property changes no schema, no triggers
and no replication contract; metadata_json is exactly the extension point UA4
introduced for this. Rows written before the field deserialize it as null.

Tests: 4 OPC UA + 6 MxGateway mapper cases, 3 NativeAlarmActor (emit,
failover rehydrate, pre-AckTime row), 1 proto round-trip incl. the null case,
4 Commons additive/back-compat. The OPC UA SelectClause count lock-in moves
18 -> 19 with an index-18 assertion -- intended, the clause is appended, which
is precisely what that guard exists to make visible.
2026-08-01 13:12:04 -04:00

188 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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>Clamps the gateway severity onto the unified 0–1000 scale.</summary>
/// <param name="severity">Raw gateway severity value to clamp.</param>
/// <returns>The severity clamped to the range [0, 1000].</returns>
public static int NormalizeSeverity(int severity) => Math.Clamp(severity, 0, 1000);
/// <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">Raw gateway severity value; normalized to 0–1000.</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 new AlarmConditionState(active, acked, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(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 new AlarmConditionState(active, acked, Confirmed: null,
Shelve: AlarmShelveState.Unshelved, Suppressed: false, Severity: NormalizeSeverity(severity));
}
/// <summary>
/// Derives the ack timestamp mirrored onto <c>NativeAlarmTransition.AckTime</c>
/// (MES alarm-status API §6.4).
///
/// <para>
/// Unlike OPC UA A&amp;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));
}
}