Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmRecordTransitionMapper.cs
T
Joseph Doherty 7618cafb6c
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 1m3s
ci / java (push) Successful in 2m17s
ci / portable (push) Failing after 4m42s
fix(worker): review 2026-10-21 stream E — cache eviction, phased ReadBulk, poll/STA fault handling
- RemoveItem/Unregister evict value + write-completion caches (leak under churn)
- ReadBulk snapshot path: add/advise all, one shared deadline, then tear down
- alarm poll faults only after 3 consecutive failures; transient ones logged
- AlarmCommandHandler disposes primary when standby build throws
- StaRuntime pumps between queued work items
- WRK-02: post-start STA loop death surfaces as StaHung WorkerFault
- WRK-09: AppDomain unhandled-exception hook, exit code 7
- RETRIGGER documented as reserved, never synthesized
- TST-05: live facts for Ping/GetSessionState/GetWorkerInfo/DrainEvents/ShutdownWorker
2026-10-22 06:19:32 -04:00

205 lines
9.5 KiB
C#

using System;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Translation helpers between the wnwrapConsumer XML payload and the
/// proto-friendly <see cref="AlarmTransitionKind"/> wire format, plus
/// alarm-reference composition.
/// </summary>
/// <remarks>
/// <para>
/// These mappings stay pure and library-agnostic so they're unit
/// testable without an AVEVA install. The COM-side I/O lives on
/// <see cref="WnWrapAlarmConsumer"/>.
/// </para>
/// </remarks>
public static class AlarmRecordTransitionMapper
{
/// <summary>
/// Decode AVEVA's STATE string (one of <c>UNACK_ALM</c>, <c>ACK_ALM</c>,
/// <c>UNACK_RTN</c>, <c>ACK_RTN</c>) into the worker's library-agnostic
/// <see cref="MxAlarmStateKind"/>. Unknown values map to
/// <see cref="MxAlarmStateKind.Unspecified"/>.
/// </summary>
/// <param name="stateXml">The state XML string from AVEVA (e.g., UNACK_ALM, ACK_RTN).</param>
/// <returns>The parsed <see cref="MxAlarmStateKind"/>, or <see cref="MxAlarmStateKind.Unspecified"/> when unrecognized.</returns>
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,
};
}
/// <summary>
/// Decide which proto transition kind a state change represents.
/// The decision table:
/// <list type="bullet">
/// <item><description><c>previous=Unspecified</c> + <c>current=*Alm</c> → Raise (new alarm).</description></item>
/// <item><description><c>previous=Unspecified</c> + <c>current=*Rtn</c> → Clear (alarm appeared in cleared state — rare; missed the raise).</description></item>
/// <item><description><c>previous=Unack*</c> + <c>current=Ack*</c> → Acknowledge.</description></item>
/// <item><description><c>previous=*Alm</c> + <c>current=*Rtn</c> → Clear.</description></item>
/// <item><description><c>previous=*Rtn</c> + <c>current=*Alm</c> → Raise (re-trigger after clear).</description></item>
/// <item><description>Anything else → Unspecified (no proto kind to emit).</description></item>
/// </list>
/// <para>
/// <see cref="AlarmTransitionKind.Retrigger"/> 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
/// <c>*_ALM</c>/<c>*_RTN</c> 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").
/// </para>
/// </summary>
/// <param name="previous">The previous alarm state kind.</param>
/// <param name="current">The current alarm state kind.</param>
/// <returns>The proto <see cref="AlarmTransitionKind"/> that the state change represents.</returns>
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;
}
/// <summary>
/// Compose <c>alarm_full_reference</c> as <c>Provider!Group.AlarmName</c>.
/// 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.
/// </summary>
/// <param name="providerName">The provider name, or null.</param>
/// <param name="groupName">The group name, or null.</param>
/// <param name="alarmName">The alarm name, or null.</param>
/// <returns>The composed <c>Provider!Group.AlarmName</c> reference string.</returns>
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}";
}
/// <summary>
/// Reassemble a UTC <see cref="DateTime"/> from the wnwrap XML's
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
/// fields. Returns <see cref="DateTime.MinValue"/> 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).
/// </summary>
/// <param name="xmlDate">e.g. <c>"2026/5/1"</c> (no zero-padding).</param>
/// <param name="xmlTime">e.g. <c>"13:26:14.709"</c>.</param>
/// <param name="gmtOffsetMinutes">Offset of the producer's local time vs UTC, in minutes.</param>
/// <param name="dstAdjustMinutes">DST adjustment already applied to local time, in minutes.</param>
/// <returns>The reassembled UTC timestamp, or <see cref="DateTime.MinValue"/> when parsing fails.</returns>
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);
}
}