using System; using ZB.MOM.WW.MxGateway.Contracts.Proto; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// /// Translation helpers between the wnwrapConsumer XML payload and the /// proto-friendly wire format, plus /// alarm-reference composition. /// /// /// /// These mappings stay pure and library-agnostic so they're unit /// testable without an AVEVA install. The COM-side I/O lives on /// . /// /// public static class AlarmRecordTransitionMapper { /// /// Decode AVEVA's STATE string (one of UNACK_ALM, ACK_ALM, /// UNACK_RTN, ACK_RTN) into the worker's library-agnostic /// . Unknown values map to /// . /// /// The state XML string from AVEVA (e.g., UNACK_ALM, ACK_RTN). /// The parsed , or when unrecognized. public static MxAlarmStateKind ParseStateKind(string? stateXml) { if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified; return stateXml!.Trim().ToUpperInvariant() switch { "UNACK_ALM" => MxAlarmStateKind.UnackAlm, "ACK_ALM" => MxAlarmStateKind.AckAlm, "UNACK_RTN" => MxAlarmStateKind.UnackRtn, "ACK_RTN" => MxAlarmStateKind.AckRtn, _ => MxAlarmStateKind.Unspecified, }; } /// /// Decide which proto transition kind a state change represents. /// The decision table: /// /// previous=Unspecified + current=*Alm → Raise (new alarm). /// previous=Unspecified + current=*Rtn → Clear (alarm appeared in cleared state — rare; missed the raise). /// previous=Unack* + current=Ack* → Acknowledge. /// previous=*Alm + current=*Rtn → Clear. /// previous=*Rtn + current=*Alm → Raise (re-trigger after clear). /// Anything else → Unspecified (no proto kind to emit). /// /// /// is never returned. It is /// reserved in the contract, but neither alarm source gives the worker /// a native re-alarm signal: the alarmmgr XML carries only the four /// *_ALM/*_RTN states, so a return-to-alarm is /// indistinguishable from a fresh raise and is reported as Raise, and /// the subtag source observes no re-alarm counter. Deliberately not /// synthesized (the gateway forwards only transitions the source /// reports — see docs/AlarmClientDiscovery.md, "Retrigger /// transitions"). /// /// /// The previous alarm state kind. /// The current alarm state kind. /// The proto that the state change represents. public static AlarmTransitionKind MapTransition( MxAlarmStateKind previous, MxAlarmStateKind current) { if (current == MxAlarmStateKind.Unspecified) return AlarmTransitionKind.Unspecified; bool currentIsAlm = current is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm; bool currentIsRtn = current is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn; bool currentIsAcked = current is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn; if (previous == MxAlarmStateKind.Unspecified) { return currentIsAlm ? AlarmTransitionKind.Raise : AlarmTransitionKind.Clear; } bool previousIsAlm = previous is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm; bool previousIsRtn = previous is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn; bool previousIsAcked = previous is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn; if (previousIsAlm && currentIsRtn) return AlarmTransitionKind.Clear; if (previousIsRtn && currentIsAlm) return AlarmTransitionKind.Raise; if (!previousIsAcked && currentIsAcked) return AlarmTransitionKind.Acknowledge; return AlarmTransitionKind.Unspecified; } /// /// Compose alarm_full_reference as Provider!Group.AlarmName. /// The format mirrors AVEVA's standard alarm-reference syntax so /// downstream consumers that already speak it (e.g. the gateway's /// AcknowledgeAlarm RPC echoing a reference back as a GUID lookup) /// don't need translation. /// /// The provider name, or null. /// The group name, or null. /// The alarm name, or null. /// The composed Provider!Group.AlarmName reference string. public static string ComposeFullReference(string? providerName, string? groupName, string? alarmName) { string provider = providerName ?? string.Empty; string group = groupName ?? string.Empty; string name = alarmName ?? string.Empty; if (string.IsNullOrEmpty(provider)) { return string.IsNullOrEmpty(group) ? name : $"{group}.{name}"; } return string.IsNullOrEmpty(group) ? $"{provider}!{name}" : $"{provider}!{group}.{name}"; } /// /// Reassemble a UTC from the wnwrap XML's /// DATE + TIME + GMTOFFSET + DSTADJUST /// fields. Returns when DATE / TIME /// can't be parsed (best-effort — failure is non-fatal; the proto /// will carry the epoch and the EventQueue's fault counter records /// the parse miss). /// /// e.g. "2026/5/1" (no zero-padding). /// e.g. "13:26:14.709". /// Offset of the producer's local time vs UTC, in minutes. /// DST adjustment already applied to local time, in minutes. /// The reassembled UTC timestamp, or when parsing fails. public static DateTime ParseTransitionTimestampUtc( string? xmlDate, string? xmlTime, int gmtOffsetMinutes, int dstAdjustMinutes) { if (string.IsNullOrWhiteSpace(xmlDate) || string.IsNullOrWhiteSpace(xmlTime)) { return DateTime.MinValue; } // Parse DATE: yyyy/M/d (no zero padding observed). Use ParseExact with // multiple format candidates — AVEVA's locale may format differently // on non-en-US hosts. string[] dateFormats = { "yyyy/M/d", "yyyy/MM/dd", "M/d/yyyy", "MM/dd/yyyy", "d/M/yyyy", "dd/MM/yyyy", }; string dateTrim = xmlDate!.Trim(); if (!DateTime.TryParseExact( dateTrim, dateFormats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateTime date)) { if (!DateTime.TryParse( dateTrim, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out date)) { return DateTime.MinValue; } } // Parse TIME: H:m:s.fff (variable precision). string[] timeFormats = { "H:m:s.fff", "H:m:s.ff", "H:m:s.f", "H:m:s", "HH:mm:ss.fff", "HH:mm:ss.ff", "HH:mm:ss.f", "HH:mm:ss", }; string timeTrim = xmlTime!.Trim(); if (!DateTime.TryParseExact( timeTrim, timeFormats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out DateTime time)) { if (!DateTime.TryParse( timeTrim, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out time)) { return DateTime.MinValue; } } DateTime localProducerTime = new DateTime( date.Year, date.Month, date.Day, time.Hour, time.Minute, time.Second, time.Millisecond, DateTimeKind.Unspecified); // GMTOFFSET = minutes east of UTC (or behind, depending on convention). // The wnwrap convention observed: GMTOFFSET=240, DSTADJUST=0 for // EDT (4 hours behind UTC) — so the field is "minutes from local to UTC". To get // UTC, ADD the offset. DateTime utc = localProducerTime.AddMinutes(gmtOffsetMinutes - dstAdjustMinutes); return DateTime.SpecifyKind(utc, DateTimeKind.Utc); } }