7618cafb6c
- 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
205 lines
9.5 KiB
C#
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);
|
|
}
|
|
}
|