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; /// /// Pure mapping from MxAccess Gateway alarm-feed proto messages to the /// protocol-neutral shape. The gateway proto /// enums (AlarmConditionState / AlarmTransitionKind) collide with the Commons /// alarm enums, so they are aliased here. Unit-tested without a live gateway. /// public static class MxGatewayAlarmMapper { /// Clamps the gateway severity onto the unified 0–1000 scale. /// Raw gateway severity value to clamp. /// The severity clamped to the range [0, 1000]. public static int NormalizeSeverity(int severity) => Math.Clamp(severity, 0, 1000); /// Maps a gateway condition-state + severity to the unified condition. /// Gateway proto condition state to map. /// Raw gateway severity value; normalized to 0–1000. /// The protocol-neutral equivalent. 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)); } /// Maps a gateway transition kind to the unified transition kind. /// Gateway proto transition kind to map. /// The protocol-neutral equivalent. 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 }; /// Derives the mirrored condition from a transition kind + severity. 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)); } /// /// Derives the ack timestamp mirrored onto NativeAlarmTransition.AckTime /// (MES alarm-status API §6.4). /// /// /// Unlike OPC UA A&C, the MxAccess Gateway alarm feed carries NO dedicated ack /// timestamp — an acknowledgement arrives as an ACTIVE_ACKED condition state on a /// transition whose only time is (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. /// /// /// /// Returns null unless the condition is active AND acknowledged — matching /// OpcUaAlarmMapper.DeriveAckTime, so both native protocols agree on "null while /// unacked, cleared on re-raise". This matters especially here because the gateway maps /// INACTIVE to acked = true: without the active check, every return-to-normal /// would report an ack it never observed. /// /// /// The mirrored condition state for the transition. /// The transition's timestamp — the DCL's observation instant for the ack. /// The observed ack timestamp, or null when the condition is not an outstanding acknowledged alarm. public static DateTimeOffset? DeriveAckTime(AlarmConditionState condition, DateTimeOffset transitionTime) => condition is { Active: true, Acknowledged: true } ? transitionTime : null; /// /// Converts an union to a display-only string using /// and invariant culture formatting, /// so numeric values always use '.' as the decimal separator. Null or unset /// values produce an empty string. /// /// The gateway value union to convert, or null. /// The value formatted as an invariant-culture string, or an empty string if null or unset. internal static string MxValueToString(MxValue? mxVal) { if (mxVal is null) return ""; var clr = mxVal.ToClrValue(); return clr is null ? "" : Convert.ToString(clr, CultureInfo.InvariantCulture) ?? ""; } /// Maps a live to a transition. /// The gateway alarm transition event proto message to map. /// The protocol-neutral . 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!.."). 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)); } /// The end-of-snapshot sentinel transition (no condition payload). /// A with AlarmTransitionKind.SnapshotComplete. public static NativeAlarmTransition SnapshotComplete() => new( "", "", "", AlarmTransitionKind.SnapshotComplete, new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0), "", "", "", "", "", null, DateTimeOffset.UtcNow, "", ""); /// Maps one initial-snapshot entry to a Snapshot transition. /// The active alarm snapshot proto message to map. /// A with AlarmTransitionKind.Snapshot. 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!.." 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)); } }