693a78db7d
The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. No client and no operator could tell a complete active set
from a capped one.
Two additive proto3 booleans carry the verdict out:
- QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)
The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.
Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.
Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.
Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.
Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.
Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
970 lines
45 KiB
C#
970 lines
45 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Runtime.InteropServices;
|
|
using System.Xml;
|
|
using WNWRAPCONSUMERLib;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Production <see cref="IMxAccessAlarmConsumer"/> backed by AVEVA's
|
|
/// standalone <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> COM object
|
|
/// (CLSID <c>{7AB52E5F-36B2-4A30-AE46-952A746F667C}</c>, hosted by
|
|
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Replaces the earlier <c>AlarmClientConsumer</c> built on
|
|
/// <c>aaAlarmManagedClient.AlarmClient</c>, which crashed in
|
|
/// <c>GetHighPriAlarm</c> with <c>ArgumentOutOfRangeException</c>
|
|
/// (FILETIME→DateTime auto-marshaling on AVEVA's sentinel timestamps).
|
|
/// The wnwrap surface returns the alarm record as a BSTR XML string
|
|
/// via <c>GetXmlCurrentAlarms2</c>; timestamps arrive as ASCII
|
|
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
|
|
/// fields and never touch the .NET DateTime marshaler. See
|
|
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" for
|
|
/// the discovery and the captured payload schema.
|
|
/// </para>
|
|
/// <para>
|
|
/// <strong>Threading.</strong> The wnwrap CLSID is registered with
|
|
/// <c>ThreadingModel=Apartment</c>. The consumer must be created
|
|
/// and operated from an STA thread; the worker's
|
|
/// <see cref="MxAccessStaSession"/> runs an STA pump that hosts it.
|
|
/// The consumer owns <em>no</em> internal timer: every COM call
|
|
/// (<c>Subscribe</c>, <c>PollOnce</c>, <c>AcknowledgeBy*</c>) must
|
|
/// be invoked on the STA that created the consumer. Polling cadence
|
|
/// is driven externally by the worker's STA via
|
|
/// <c>StaRuntime.InvokeAsync(() => consumer.PollOnce())</c>, which
|
|
/// keeps every <c>GetXmlCurrentAlarms2</c> call on the apartment that
|
|
/// owns the COM object. A thread-pool timer would call the COM API
|
|
/// off the owning STA and can deadlock on cross-apartment marshaling
|
|
/// when the STA is not pumping messages, so no such timer exists.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|
{
|
|
private const string DefaultProductName = "OtOpcUa.MxGateway";
|
|
private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker";
|
|
private const string DefaultVersion = "1.0";
|
|
private const int DefaultMaxAlarmsPerFetch = 1024;
|
|
|
|
/// <summary>
|
|
/// Floor on the resolved per-fetch cap. Mirrors the gateway-side
|
|
/// <c>MxGateway:Alarms:MaxAlarmsPerFetch</c> minimum so a value that
|
|
/// slipped past startup validation still leaves a usable snapshot
|
|
/// window.
|
|
/// </summary>
|
|
internal const int MinimumMaxAlarmsPerFetch = 64;
|
|
|
|
/// <summary>
|
|
/// Ceiling on the resolved per-fetch cap. Mirrors the gateway-side
|
|
/// <c>MxGateway:Alarms:MaxAlarmsPerFetch</c> maximum. The worker is a
|
|
/// 32-bit process: every poll materializes the whole reply as one BSTR
|
|
/// and then a full <see cref="XmlDocument"/> over it, so an unbounded
|
|
/// cap (<see cref="int.MaxValue"/> passed startup validation before
|
|
/// this existed) is an out-of-memory fault on the STA, not a slow poll.
|
|
/// </summary>
|
|
internal const int MaximumMaxAlarmsPerFetch = 65_536;
|
|
|
|
/// <summary>
|
|
/// Environment variable the gateway's <c>WorkerProcessLauncher</c>
|
|
/// sets from <c>MxGateway:Alarms:MaxAlarmsPerFetch</c>. A missing,
|
|
/// unparseable, or out-of-range value falls back to
|
|
/// <see cref="DefaultMaxAlarmsPerFetch"/> — a bad environment value
|
|
/// must never keep the alarm consumer from starting.
|
|
/// </summary>
|
|
internal const string MaxAlarmsPerFetchEnvironmentVariableName =
|
|
"MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
|
|
|
|
/// <summary>
|
|
/// Minimum gap between truncated-fetch warnings. A galaxy parked above
|
|
/// the cap truncates on *every* poll, so an unthrottled warning would
|
|
/// be two log lines a second forever.
|
|
/// </summary>
|
|
private const long TruncationWarningIntervalMilliseconds = 60_000;
|
|
|
|
private readonly object syncRoot = new object();
|
|
private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
|
|
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
|
private readonly int maxAlarmsPerFetch;
|
|
private readonly Stopwatch truncationWarningClock = Stopwatch.StartNew();
|
|
|
|
private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds;
|
|
private long truncatedFetchCount;
|
|
private bool lastSnapshotTruncated;
|
|
private wwAlarmConsumerClass? client;
|
|
private wwAlarmConsumerClass? ackClient;
|
|
private bool subscribed;
|
|
private bool disposed;
|
|
|
|
/// <summary>
|
|
/// Production constructor — creates the wnwrap COM object on the
|
|
/// current thread (which must be the worker's STA). Polling is driven
|
|
/// externally by the STA via
|
|
/// <c>StaRuntime.InvokeAsync(() => consumer.PollOnce())</c> so that
|
|
/// every COM call stays on the STA that owns the apartment.
|
|
/// </summary>
|
|
public WnWrapAlarmConsumer()
|
|
: this(new wwAlarmConsumerClass(), ResolveMaxAlarmsPerFetch())
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test seam / explicit construction.
|
|
/// </summary>
|
|
/// <param name="client">The COM alarm consumer instance.</param>
|
|
/// <param name="maxAlarmsPerFetch">Maximum alarms per fetch call.</param>
|
|
public WnWrapAlarmConsumer(
|
|
wwAlarmConsumerClass client,
|
|
int maxAlarmsPerFetch)
|
|
{
|
|
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
|
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
|
|
? maxAlarmsPerFetch
|
|
: DefaultMaxAlarmsPerFetch;
|
|
}
|
|
|
|
/// <summary>
|
|
/// COM-free construction, for exercising the retained-snapshot state
|
|
/// machine (<see cref="FoldFetch"/> / <see cref="LastSnapshotTruncated"/>
|
|
/// / <see cref="SnapshotActiveAlarms"/>) on a machine without AVEVA
|
|
/// installed. <see cref="Subscribe"/> throws and <see cref="PollOnce"/>
|
|
/// no-ops on an instance built this way — both need the wnwrap coclass,
|
|
/// which cannot be instantiated on the macOS/Linux test matrix. Internal
|
|
/// rather than public so it cannot be reached from production wiring.
|
|
/// </summary>
|
|
/// <param name="maxAlarmsPerFetch">Maximum alarms per fetch call.</param>
|
|
internal WnWrapAlarmConsumer(int maxAlarmsPerFetch)
|
|
{
|
|
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
|
|
? maxAlarmsPerFetch
|
|
: DefaultMaxAlarmsPerFetch;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the per-fetch cap from the launcher-provided environment
|
|
/// variable. A missing, unparseable, or out-of-range value falls back
|
|
/// to <see cref="DefaultMaxAlarmsPerFetch"/> rather than throwing:
|
|
/// polling at the default cap is always safe, and a session that
|
|
/// refuses to subscribe over a mistyped environment variable is not.
|
|
/// The ceiling is enforced here as well as in the gateway validator so
|
|
/// a hand-set environment (or a future launcher bug) cannot hand the
|
|
/// x86 worker a cap that faults it on the first poll.
|
|
/// </summary>
|
|
/// <returns>The cap passed to <c>GetXmlCurrentAlarms2</c>.</returns>
|
|
internal static int ResolveMaxAlarmsPerFetch()
|
|
{
|
|
string? value = Environment.GetEnvironmentVariable(MaxAlarmsPerFetchEnvironmentVariableName);
|
|
return int.TryParse(
|
|
value,
|
|
NumberStyles.Integer,
|
|
CultureInfo.InvariantCulture,
|
|
out int cap)
|
|
&& cap >= MinimumMaxAlarmsPerFetch
|
|
&& cap <= MaximumMaxAlarmsPerFetch
|
|
? cap
|
|
: DefaultMaxAlarmsPerFetch;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fires once per detected alarm-state transition (raise, acknowledge,
|
|
/// clear, or new-alarm-already-acked-on-arrival), dispatched from
|
|
/// <see cref="PollOnce"/> on the calling (STA) thread.
|
|
/// </summary>
|
|
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
|
|
|
/// <inheritdoc />
|
|
public void Subscribe(string subscription)
|
|
{
|
|
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
|
|
lock (syncRoot)
|
|
{
|
|
if (subscribed)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"WnWrapAlarmConsumer.Subscribe was called more than once; " +
|
|
"wwAlarmConsumerClass.Subscribe replaces the previous filter and is not idempotent.");
|
|
}
|
|
|
|
wwAlarmConsumerClass com = client
|
|
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
|
|
// Use the IwwAlarmConsumer (v1) prefix-named methods for the
|
|
// lifecycle. Empirically (live dev-rig 2026-05-01) this is the
|
|
// only path that lets AlarmAckByName succeed afterwards. The
|
|
// v2 Initialize/Register/Subscribe methods on the class
|
|
// succeed (return 0) but acks against that consumer state
|
|
// return -55.
|
|
int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
|
|
if (init != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"wwAlarmConsumer.InitializeConsumer returned non-zero status {init}.");
|
|
}
|
|
|
|
// hWnd=0: wnwrap supports a pull-based model — no message pump
|
|
// is required. GetXmlCurrentAlarms2 is polled by the worker's STA
|
|
// via StaRuntime.InvokeAsync(() => consumer.PollOnce()); this type
|
|
// owns no internal timer.
|
|
int reg = com.IwwAlarmConsumer_RegisterConsumer(
|
|
hWnd: 0,
|
|
szProductName: DefaultProductName,
|
|
szApplicationName: DefaultApplicationName,
|
|
szVersion: DefaultVersion);
|
|
if (reg != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"wwAlarmConsumer.RegisterConsumer returned non-zero status {reg}.");
|
|
}
|
|
|
|
int sub = com.IwwAlarmConsumer_Subscribe(
|
|
szSubscription: subscription,
|
|
wFromPri: 1,
|
|
wToPri: 999,
|
|
QueryType: eQueryType.qtSummary,
|
|
SortFlags: eSortFlags.sfReturnNewestFirst,
|
|
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
|
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
|
if (sub != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"wwAlarmConsumer.Subscribe('{subscription}') returned non-zero status {sub}.");
|
|
}
|
|
|
|
// Empirically required: even though the round-trip echo of
|
|
// SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md),
|
|
// calling it is necessary for subsequent GetXmlCurrentAlarms2
|
|
// calls to succeed. Without it, GetXmlCurrentAlarms2 returns
|
|
// E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery
|
|
// also breaks AlarmAckByName on the same consumer (rejects with
|
|
// -55), so a separate ack-only consumer is provisioned below
|
|
// that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
|
|
//
|
|
// The wnwrap interop signature is `void SetXmlAlarmQuery(string)`
|
|
// — there is no integer return code to gate on like the other v1
|
|
// lifecycle calls in this method. A genuine failure surfaces as a
|
|
// COM exception (mapped from the underlying HRESULT). Wrap the
|
|
// call so a failure becomes an InvalidOperationException with
|
|
// diagnostic context, matching the other call-gates' failure
|
|
// shape rather than letting an opaque COMException escape with
|
|
// no indication that the alarm subscription is now misconfigured
|
|
// and the next GetXmlCurrentAlarms2 poll will fail with E_FAIL.
|
|
string xmlQuery = ComposeXmlAlarmQuery(subscription);
|
|
try
|
|
{
|
|
com.SetXmlAlarmQuery(xmlQuery);
|
|
}
|
|
catch (COMException ex)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"wwAlarmConsumer.SetXmlAlarmQuery failed with HRESULT 0x{ex.HResult:X8}; " +
|
|
"subsequent GetXmlCurrentAlarms2 polls would return E_FAIL.",
|
|
ex);
|
|
}
|
|
|
|
// Provision a parallel COM consumer for ack calls. It runs the
|
|
// v1 lifecycle (Initialize/Register/Subscribe) only; without
|
|
// SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only
|
|
// — we never poll this consumer.
|
|
ackClient = new wwAlarmConsumerClass();
|
|
int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack");
|
|
int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer(
|
|
hWnd: 0,
|
|
szProductName: DefaultProductName,
|
|
szApplicationName: DefaultApplicationName + ".ack",
|
|
szVersion: DefaultVersion);
|
|
int ackSub = ackClient.IwwAlarmConsumer_Subscribe(
|
|
szSubscription: subscription,
|
|
wFromPri: 1,
|
|
wToPri: 999,
|
|
QueryType: eQueryType.qtSummary,
|
|
SortFlags: eSortFlags.sfReturnNewestFirst,
|
|
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
|
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
|
if (ackInit != 0 || ackReg != 0 || ackSub != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Ack consumer setup returned non-zero status: " +
|
|
$"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
|
|
}
|
|
|
|
subscribed = true;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public int AcknowledgeByGuid(
|
|
Guid alarmGuid,
|
|
string ackComment,
|
|
string ackOperatorName,
|
|
string ackOperatorNode,
|
|
string ackOperatorDomain,
|
|
string ackOperatorFullName)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
|
|
wwAlarmConsumerClass com = client
|
|
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
|
|
// VBGUID is wnwrap's GUID interop struct (same memory layout as
|
|
// System.Guid: int32 + 2x int16 + 8x byte). Convert via a single
|
|
// unmanaged-blittable round-trip.
|
|
VBGUID vb = ToVbGuid(alarmGuid);
|
|
return com.AlarmAckByGUID(
|
|
AlmGUID: vb,
|
|
szComment: ackComment ?? string.Empty,
|
|
szOprName: ackOperatorName ?? string.Empty,
|
|
szNode: ackOperatorNode ?? string.Empty,
|
|
szDomainName: ackOperatorDomain ?? string.Empty,
|
|
szOprFullName: ackOperatorFullName ?? string.Empty);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public int AcknowledgeByName(
|
|
string alarmName,
|
|
string providerName,
|
|
string groupName,
|
|
string ackComment,
|
|
string ackOperatorName,
|
|
string ackOperatorNode,
|
|
string ackOperatorDomain,
|
|
string ackOperatorFullName)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
|
|
// Use the parallel ack-only consumer (no SetXmlAlarmQuery applied)
|
|
// — see docs/AlarmClientDiscovery.md "Option A — captured" for the
|
|
// empirical justification.
|
|
wwAlarmConsumerClass com = ackClient
|
|
?? throw new InvalidOperationException(
|
|
"Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet.");
|
|
|
|
// Empirically (live dev-rig 2026-05-01): the IwwAlarmConsumer2
|
|
// 8-arg AlarmAckByName returns -55 on this AVEVA build (looks like
|
|
// a stub). The legacy 6-arg IwwAlarmConsumer.AlarmAckByName works
|
|
// and reaches the alarm-history path correctly. Operator-domain
|
|
// and operator-full-name fields are accepted by the proto contract
|
|
// for forward-compat but are not propagated to AVEVA today —
|
|
// wrapped in the 6-arg call so domain/full-name go to the
|
|
// alarm-history operator-name field via the szOprName parameter.
|
|
// Suppress unused-warning explicitly:
|
|
_ = ackOperatorDomain;
|
|
_ = ackOperatorFullName;
|
|
return com.AlarmAckByName(
|
|
szAlarmName: alarmName ?? string.Empty,
|
|
szProviderName: providerName ?? string.Empty,
|
|
szGroupName: groupName ?? string.Empty,
|
|
szComment: ackComment ?? string.Empty,
|
|
szOprName: ackOperatorName ?? string.Empty,
|
|
szNode: ackOperatorNode ?? string.Empty);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// This is the set the gateway's reconcile pass diffs its cache
|
|
/// against, so an alarm missing here is what the gateway reads as a
|
|
/// Clear. After a truncated fetch the snapshot deliberately still
|
|
/// carries alarms the capped reply could not mention — see
|
|
/// <see cref="PollOnce"/>. Erring toward "still active" keeps the feed
|
|
/// at-least-once: a stale entry is repaired by the next sub-cap poll,
|
|
/// whereas a dropped one is broadcast as a Clear that never happened.
|
|
/// </remarks>
|
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
lock (syncRoot)
|
|
{
|
|
List<MxAlarmSnapshotRecord> active = new List<MxAlarmSnapshotRecord>();
|
|
foreach (MxAlarmSnapshotRecord record in latestSnapshot.Values)
|
|
{
|
|
if (record.State == MxAlarmStateKind.UnackAlm
|
|
|| record.State == MxAlarmStateKind.AckAlm)
|
|
{
|
|
active.Add(record);
|
|
}
|
|
}
|
|
return active;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
/// <remarks>
|
|
/// Read without the disposed guard <see cref="SnapshotActiveAlarms"/>
|
|
/// carries: this is degraded-status metadata a reply builder stamps
|
|
/// alongside a snapshot, and throwing from it would fail a query whose
|
|
/// snapshot half succeeded.
|
|
/// </remarks>
|
|
public bool LastSnapshotTruncated
|
|
{
|
|
get { lock (syncRoot) { return lastSnapshotTruncated; } }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sink for the rate-limited truncated-fetch warning. Defaults to
|
|
/// <see cref="Console.Error"/>, the stream
|
|
/// <c>WorkerConsoleLogger</c> already writes to, so the line lands in
|
|
/// the worker's captured stderr alongside the rest of its log. Tests
|
|
/// substitute a collector. The message carries identifiers and counts
|
|
/// only — never alarm values, tag names, or comments.
|
|
/// </summary>
|
|
internal Action<string>? TruncationWarningSink { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
public void PollOnce()
|
|
{
|
|
wwAlarmConsumerClass? com;
|
|
lock (syncRoot)
|
|
{
|
|
if (disposed || !subscribed) return;
|
|
com = client;
|
|
}
|
|
if (com is null) return;
|
|
|
|
object xmlObj = string.Empty;
|
|
com.GetXmlCurrentAlarms2(maxAlmCnt: maxAlarmsPerFetch, vartCurrentXmlAlarms: out xmlObj);
|
|
string xml = xmlObj?.ToString() ?? string.Empty;
|
|
if (xml.Length == 0) return;
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml, out int fetchedRecordCount);
|
|
|
|
// TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt
|
|
// and gives no *verified* "there is more" flag, so a reply holding
|
|
// exactly the cap is indistinguishable from a galaxy that happens to
|
|
// have exactly that many active alarms. Treat the ambiguous case as
|
|
// truncated: the false-positive cost is a snapshot that stays stale for
|
|
// one poll, the false-negative cost is every alarm past the cap reading
|
|
// as cleared. (The reply's ALARM_RECORDS/@COUNT attribute is a
|
|
// candidate exact signal, but only if it reports the total rather than
|
|
// the records in the reply — untested on a live rig, see
|
|
// docs/AlarmProbeFindings.md.)
|
|
bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch);
|
|
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
FoldFetch(next, truncated, out int retainedCount);
|
|
|
|
if (truncated)
|
|
{
|
|
WarnTruncatedFetch(fetchedRecordCount, next.Count, retainedCount);
|
|
}
|
|
|
|
if (transitions.Count == 0) return;
|
|
EventHandler<MxAlarmTransitionEvent>? handler = AlarmTransitionEmitted;
|
|
if (handler is null) return;
|
|
foreach (MxAlarmTransitionEvent transition in transitions)
|
|
{
|
|
handler.Invoke(this, transition);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Folds one fetch into the retained state under a single lock: the
|
|
/// transition diff, the snapshot merge/replace, and the truncation
|
|
/// verdict move together. Splitting them would let a concurrent
|
|
/// <see cref="SnapshotActiveAlarms"/> / <see cref="LastSnapshotTruncated"/>
|
|
/// pair read a capped snapshot alongside the previous poll's "complete"
|
|
/// verdict — precisely the false all-clear the signal exists to prevent.
|
|
/// The verdict is replaced, never latched: a sub-cap fetch is complete
|
|
/// and restores absence authority, so leaving the flag set would strand
|
|
/// the operator banner on after a single burst.
|
|
/// </summary>
|
|
/// <param name="next">The snapshot just parsed from the fetch.</param>
|
|
/// <param name="truncated">Whether the fetch hit the per-fetch cap.</param>
|
|
/// <param name="retainedCount">Size of the retained snapshot after the fold.</param>
|
|
/// <returns>The transitions the fetch implies.</returns>
|
|
internal IReadOnlyList<MxAlarmTransitionEvent> FoldFetch(
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next,
|
|
bool truncated,
|
|
out int retainedCount)
|
|
{
|
|
lock (syncRoot)
|
|
{
|
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
|
ComputeTransitions(latestSnapshot, next);
|
|
ApplySnapshotUpdate(latestSnapshot, next, truncated);
|
|
lastSnapshotTruncated = truncated;
|
|
retainedCount = latestSnapshot.Count;
|
|
return transitions;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decides whether a fetch that came back holding
|
|
/// <paramref name="fetchedRecordCount"/> records hit the cap.
|
|
/// <c>GetXmlCurrentAlarms2</c> caps its reply at <c>maxAlmCnt</c> and
|
|
/// offers no confirmed "more available" flag, so a reply at exactly the
|
|
/// cap is indistinguishable from a galaxy that happens to hold exactly
|
|
/// that many active alarms; both are treated as truncated. The reply
|
|
/// root carries an <c>ALARM_RECORDS/@COUNT</c> attribute that would make
|
|
/// the test exact if it reported the total active count rather than the
|
|
/// records in this reply; a live probe could not discriminate the two
|
|
/// (see <c>docs/AlarmProbeFindings.md</c>), so the count is deliberately
|
|
/// not trusted here. Exposed as <c>internal static</c> so the rule is
|
|
/// unit-testable without the wnwrapConsumer COM object.
|
|
/// </summary>
|
|
/// <param name="fetchedRecordCount">ALARM records the reply carried.</param>
|
|
/// <param name="maxAlarmsPerFetch">The cap that was passed to the fetch.</param>
|
|
/// <returns><see langword="true"/> when the reply must be treated as truncated.</returns>
|
|
internal static bool IsTruncatedFetch(int fetchedRecordCount, int maxAlarmsPerFetch)
|
|
{
|
|
return fetchedRecordCount >= maxAlarmsPerFetch;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Folds a freshly fetched snapshot into the retained one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A complete fetch is authoritative about <em>absence</em>:
|
|
/// replacing the snapshot wholesale is exactly what lets a cleared
|
|
/// alarm drop out of <see cref="SnapshotActiveAlarms"/>, which is
|
|
/// the evidence the gateway's reconcile pass turns into a Clear.
|
|
/// </para>
|
|
/// <para>
|
|
/// A truncated fetch is authoritative about <em>presence</em>
|
|
/// only. Merging rather than replacing means alarms the capped
|
|
/// reply did carry update normally, while alarms it had no room to
|
|
/// mention are retained untouched — so the gateway infers no Clear
|
|
/// from their absence. The cost is bounded staleness: a genuinely
|
|
/// cleared alarm can linger in the snapshot until the first
|
|
/// sub-cap fetch evicts it. That direction is the safe one for an
|
|
/// at-least-once alarm feed — a late Clear is repaired by the next
|
|
/// complete poll, whereas a Clear that never happened is broadcast
|
|
/// to every subscriber and cannot be taken back.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="snapshot">The retained snapshot, updated in place.</param>
|
|
/// <param name="next">The snapshot just parsed from the fetch.</param>
|
|
/// <param name="truncated">Whether the fetch hit the per-fetch cap.</param>
|
|
internal static void ApplySnapshotUpdate(
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot,
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next,
|
|
bool truncated)
|
|
{
|
|
if (snapshot is null) throw new ArgumentNullException(nameof(snapshot));
|
|
if (next is null) throw new ArgumentNullException(nameof(next));
|
|
|
|
if (!truncated)
|
|
{
|
|
snapshot.Clear();
|
|
}
|
|
|
|
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
|
{
|
|
snapshot[kv.Key] = kv.Value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits the truncated-fetch warning at most once per
|
|
/// <see cref="TruncationWarningIntervalMilliseconds"/>, counting every
|
|
/// truncated poll in between so the operator can tell a one-off burst
|
|
/// from a galaxy permanently parked above the cap.
|
|
/// </summary>
|
|
/// <param name="fetchedRecordCount">ALARM records the capped reply carried.</param>
|
|
/// <param name="parsedRecordCount">Records that survived GUID parsing.</param>
|
|
/// <param name="retainedCount">Snapshot size after the merge.</param>
|
|
private void WarnTruncatedFetch(int fetchedRecordCount, int parsedRecordCount, int retainedCount)
|
|
{
|
|
long total;
|
|
long elapsed;
|
|
lock (syncRoot)
|
|
{
|
|
total = ++truncatedFetchCount;
|
|
elapsed = truncationWarningClock.ElapsedMilliseconds;
|
|
if (!ShouldWarnTruncation(elapsed, lastTruncationWarningMilliseconds))
|
|
{
|
|
return;
|
|
}
|
|
|
|
lastTruncationWarningMilliseconds = elapsed;
|
|
}
|
|
|
|
// Format matches WorkerConsoleLogger's "level=<L> event=<E> k=v k=v"
|
|
// line shape so the worker's stderr parses uniformly. Deviation, on
|
|
// purpose: IWorkerLogger exposes only Information and Error, so
|
|
// "Warning" is a third level string no other worker line emits. A
|
|
// truncated snapshot is not an error (the poll succeeded and the
|
|
// snapshot is safe) but it is not routine either, so downgrading it to
|
|
// Information would bury it. Revisit if the truncation signal becomes
|
|
// structural — see docs/DesignDecisions.md.
|
|
//
|
|
// Identifiers and counts only — no tag names, values, limits, or
|
|
// comments, per the gateway's "don't log tag values by default" rule.
|
|
// The trailing note stays a single bare k=v token (no spaces, no
|
|
// semicolons); remediation prose lives in docs/GatewayConfiguration.md.
|
|
string message = string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"level=Warning event=AlarmSnapshotTruncated maxAlarmsPerFetch={0} fetchedRecords={1} "
|
|
+ "parsedRecords={2} retainedSnapshotSize={3} truncatedFetchesSinceStart={4} "
|
|
+ "note=truncated-snapshot-retained",
|
|
maxAlarmsPerFetch,
|
|
fetchedRecordCount,
|
|
parsedRecordCount,
|
|
retainedCount,
|
|
total);
|
|
|
|
Action<string> sink = TruncationWarningSink ?? Console.Error.WriteLine;
|
|
sink(message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rate-limit decision for the truncated-fetch warning: emit only when
|
|
/// a full <see cref="TruncationWarningIntervalMilliseconds"/> has
|
|
/// elapsed since the last one. A galaxy parked above the cap truncates
|
|
/// on every poll, so consecutive truncated polls inside one interval
|
|
/// must produce exactly one line. Exposed as <c>internal static</c>
|
|
/// so the throttle is unit-testable without the wnwrapConsumer COM
|
|
/// object.
|
|
/// </summary>
|
|
/// <param name="elapsedMilliseconds">Consumer-lifetime clock reading for this poll.</param>
|
|
/// <param name="lastWarningMilliseconds">Clock reading when the last warning was emitted.</param>
|
|
/// <returns><see langword="true"/> when this poll may emit a warning.</returns>
|
|
internal static bool ShouldWarnTruncation(
|
|
long elapsedMilliseconds,
|
|
long lastWarningMilliseconds)
|
|
{
|
|
return elapsedMilliseconds - lastWarningMilliseconds >= TruncationWarningIntervalMilliseconds;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure snapshot-to-transitions diff. Compares the previous polled
|
|
/// snapshot to the next snapshot and produces one
|
|
/// <see cref="MxAlarmTransitionEvent"/> per state change. Used by
|
|
/// <see cref="PollOnce"/> after a successful
|
|
/// <c>GetXmlCurrentAlarms2</c> call; exposed as <c>internal static</c>
|
|
/// so the diff rules can be unit-tested without driving the
|
|
/// wnwrapConsumer COM object.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>Rules:</para>
|
|
/// <list type="bullet">
|
|
/// <item><description>A GUID present in <paramref name="next"/> but not in <paramref name="previous"/> produces a transition with <see cref="MxAlarmStateKind.Unspecified"/> as the previous state — first sighting.</description></item>
|
|
/// <item><description>A GUID present in both with the same <see cref="MxAlarmSnapshotRecord.State"/> produces no transition.</description></item>
|
|
/// <item><description>A GUID present in both with a different <see cref="MxAlarmSnapshotRecord.State"/> produces a transition carrying the prior state.</description></item>
|
|
/// <item><description>A GUID present in <paramref name="previous"/> but absent from <paramref name="next"/> produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them.</description></item>
|
|
/// </list>
|
|
/// <para>
|
|
/// The absence rule is deliberately inert here: this diff never
|
|
/// turns a disappearance into a Clear. The clear is inferred one
|
|
/// level up, by the gateway's reconcile pass diffing its cache
|
|
/// against <see cref="SnapshotActiveAlarms"/>, which reads the
|
|
/// snapshot <see cref="PollOnce"/> maintains from these same
|
|
/// dictionaries. That is why the truncation guard lives in
|
|
/// <see cref="PollOnce"/>'s snapshot update rather than in this
|
|
/// method: suppressing "absence implies Clear" means declining to
|
|
/// *evict* alarms a capped fetch could not mention, not declining
|
|
/// to emit a transition this method was never going to emit.
|
|
/// Passing a truncated <paramref name="next"/> straight through
|
|
/// stays correct for the alarms it does carry — first sightings
|
|
/// and state changes are computed from presence alone.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every rule above assumes the GUID identifies the alarm
|
|
/// <em>instance</em> rather than the state it is in: a re-minted
|
|
/// GUID would read as the old alarm vanishing and a new one
|
|
/// appearing, i.e. a spurious Clear plus a spurious Raise. Live
|
|
/// capture confirms stability across the active→returned leg only
|
|
/// (<c>docs/AlarmClientDiscovery.md</c>); the acknowledge leg and
|
|
/// re-raise-after-clear are assumed, not observed, because the dev
|
|
/// rig's alarm attributes reject unauthenticated writes — see
|
|
/// <c>docs/AlarmProbeFindings.md</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
|
|
/// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
|
|
/// <returns>One transition per state change in <paramref name="next"/>.</returns>
|
|
internal static IReadOnlyList<MxAlarmTransitionEvent> ComputeTransitions(
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> previous,
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> next)
|
|
{
|
|
if (previous is null) throw new ArgumentNullException(nameof(previous));
|
|
if (next is null) throw new ArgumentNullException(nameof(next));
|
|
|
|
List<MxAlarmTransitionEvent> transitions = new List<MxAlarmTransitionEvent>();
|
|
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
|
{
|
|
MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified;
|
|
if (previous.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev))
|
|
{
|
|
previousState = prev.State;
|
|
if (previousState == kv.Value.State) continue; // no transition
|
|
}
|
|
transitions.Add(new MxAlarmTransitionEvent
|
|
{
|
|
Record = kv.Value,
|
|
PreviousState = previousState,
|
|
});
|
|
}
|
|
return transitions;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse the XML payload returned by <c>GetXmlCurrentAlarms2</c>
|
|
/// into a GUID-keyed dictionary. Records with malformed GUIDs are
|
|
/// silently dropped (no fault is recorded — the next poll will
|
|
/// resync).
|
|
/// </summary>
|
|
/// <param name="xml">The XML snapshot payload.</param>
|
|
/// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
|
|
public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
|
|
{
|
|
return ParseSnapshotXml(xml, out _);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="ParseSnapshotXml(string)"/> plus the raw
|
|
/// <c>ALARM</c>-element count, which <see cref="PollOnce"/> compares
|
|
/// against the per-fetch cap to detect a truncated reply. The raw
|
|
/// count — not the dictionary size — is the right signal: records
|
|
/// dropped for a malformed GUID still consumed a slot in the capped
|
|
/// reply, so counting only the survivors would under-report and let a
|
|
/// truncated fetch pass as complete.
|
|
/// </summary>
|
|
/// <param name="xml">The XML snapshot payload.</param>
|
|
/// <param name="alarmRecordCount">Number of <c>ALARM</c> elements in the payload.</param>
|
|
/// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
|
|
internal static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(
|
|
string xml,
|
|
out int alarmRecordCount)
|
|
{
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
|
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
|
alarmRecordCount = 0;
|
|
|
|
if (string.IsNullOrWhiteSpace(xml)) return records;
|
|
|
|
XmlDocument doc = new XmlDocument();
|
|
doc.LoadXml(xml);
|
|
XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
|
|
if (alarmNodes is null) return records;
|
|
|
|
alarmRecordCount = alarmNodes.Count;
|
|
|
|
foreach (XmlNode alarmNode in alarmNodes)
|
|
{
|
|
// One pass over the child elements instead of 17 SelectSingleNode
|
|
// XPath evaluations per record. At the 1024-record cap that is the
|
|
// difference between ~17k XPath expression compilations plus child
|
|
// walks and one walk per record, on the STA that also serves reads
|
|
// and writes.
|
|
//
|
|
// Parity with the XPath it replaces: SelectSingleNode(name) picks
|
|
// the FIRST child ELEMENT of that name (ordinal, case-sensitive)
|
|
// and yields its InnerText; an absent child yields string.Empty,
|
|
// and so does a present-but-empty one. Hence: element nodes only,
|
|
// first-match-wins (null means "not seen yet"), and a single
|
|
// coalesce to string.Empty at the end — which keeps the absent and
|
|
// empty cases indistinguishable exactly as before.
|
|
//
|
|
// One theoretical divergence: XmlNode.Name is the QName, so a child
|
|
// carrying its own default-namespace declaration (<GUID xmlns="...">)
|
|
// matches here where the unprefixed XPath name test would not. It is
|
|
// theoretical — wnwrap emits no namespaces, and a namespaced ALARM
|
|
// wrapper makes the outer SelectNodes return nothing either way — and
|
|
// it errs permissive (field populated rather than silently dropped).
|
|
string? guidHex = null;
|
|
string? xmlDate = null;
|
|
string? xmlTime = null;
|
|
string? gmtOffset = null;
|
|
string? dstAdjust = null;
|
|
string? providerNode = null;
|
|
string? providerName = null;
|
|
string? group = null;
|
|
string? tagName = null;
|
|
string? type = null;
|
|
string? value = null;
|
|
string? limit = null;
|
|
string? priority = null;
|
|
string? state = null;
|
|
string? operatorNode = null;
|
|
string? operatorName = null;
|
|
string? alarmComment = null;
|
|
|
|
foreach (XmlNode child in alarmNode.ChildNodes)
|
|
{
|
|
if (child.NodeType != XmlNodeType.Element) continue;
|
|
|
|
switch (child.Name)
|
|
{
|
|
case "GUID": guidHex ??= child.InnerText; break;
|
|
case "DATE": xmlDate ??= child.InnerText; break;
|
|
case "TIME": xmlTime ??= child.InnerText; break;
|
|
case "GMTOFFSET": gmtOffset ??= child.InnerText; break;
|
|
case "DSTADJUST": dstAdjust ??= child.InnerText; break;
|
|
case "PROVIDER_NODE": providerNode ??= child.InnerText; break;
|
|
case "PROVIDER_NAME": providerName ??= child.InnerText; break;
|
|
case "GROUP": group ??= child.InnerText; break;
|
|
case "TAGNAME": tagName ??= child.InnerText; break;
|
|
case "TYPE": type ??= child.InnerText; break;
|
|
case "VALUE": value ??= child.InnerText; break;
|
|
case "LIMIT": limit ??= child.InnerText; break;
|
|
case "PRIORITY": priority ??= child.InnerText; break;
|
|
case "STATE": state ??= child.InnerText; break;
|
|
case "OPERATOR_NODE": operatorNode ??= child.InnerText; break;
|
|
case "OPERATOR_NAME": operatorName ??= child.InnerText; break;
|
|
case "ALARM_COMMENT": alarmComment ??= child.InnerText; break;
|
|
}
|
|
}
|
|
|
|
if (!TryParseHexGuid(guidHex, out Guid guid)) continue;
|
|
|
|
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
|
|
xmlDate ?? string.Empty,
|
|
xmlTime ?? string.Empty,
|
|
ParseInt(gmtOffset ?? string.Empty),
|
|
ParseInt(dstAdjust ?? string.Empty));
|
|
|
|
records[guid] = new MxAlarmSnapshotRecord
|
|
{
|
|
AlarmGuid = guid,
|
|
TransitionTimestampUtc = tsUtc,
|
|
ProviderNode = providerNode ?? string.Empty,
|
|
ProviderName = providerName ?? string.Empty,
|
|
Group = group ?? string.Empty,
|
|
TagName = tagName ?? string.Empty,
|
|
Type = type ?? string.Empty,
|
|
Value = value ?? string.Empty,
|
|
Limit = limit ?? string.Empty,
|
|
Priority = ParseInt(priority ?? string.Empty),
|
|
State = AlarmRecordTransitionMapper.ParseStateKind(state ?? string.Empty),
|
|
OperatorNode = operatorNode ?? string.Empty,
|
|
OperatorName = operatorName ?? string.Empty,
|
|
AlarmComment = alarmComment ?? string.Empty,
|
|
};
|
|
}
|
|
return records;
|
|
}
|
|
|
|
private static int ParseInt(string text)
|
|
{
|
|
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
|
|
? n : 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// wnwrap's XML <c>GUID</c> field is a 32-char hex string with no
|
|
/// dashes (e.g. <c>"BCC4705395424D65BDAABCDEA6A32A73"</c>). Convert
|
|
/// to <see cref="Guid"/>'s canonical 8-4-4-4-12 layout.
|
|
/// </summary>
|
|
/// <param name="hex">The 32-character hex GUID string.</param>
|
|
/// <param name="guid">The parsed GUID, or Empty if parsing fails.</param>
|
|
/// <returns><see langword="true"/> if <paramref name="hex"/> was successfully parsed; otherwise, <see langword="false"/>.</returns>
|
|
public static bool TryParseHexGuid(string? hex, out Guid guid)
|
|
{
|
|
guid = Guid.Empty;
|
|
if (string.IsNullOrWhiteSpace(hex)) return false;
|
|
string trimmed = hex!.Trim();
|
|
if (Guid.TryParse(trimmed, out guid)) return true;
|
|
if (trimmed.Length != 32) return false;
|
|
string canonical =
|
|
trimmed.Substring(0, 8) + "-" +
|
|
trimmed.Substring(8, 4) + "-" +
|
|
trimmed.Substring(12, 4) + "-" +
|
|
trimmed.Substring(16, 4) + "-" +
|
|
trimmed.Substring(20, 12);
|
|
return Guid.TryParse(canonical, out guid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compose the XML payload <c>SetXmlAlarmQuery</c> expects from a
|
|
/// canonical subscription expression
|
|
/// (<c>\\<machine>\Galaxy!<area></c>). The wnwrap
|
|
/// consumer mangles the round-trip but evidently still needs the
|
|
/// call — without it <c>GetXmlCurrentAlarms2</c> fails with
|
|
/// E_FAIL. Best-effort parse: if the subscription doesn't decompose
|
|
/// cleanly, fall back to a permissive ALL-priority/ALL-state form
|
|
/// so the worker doesn't fail to start.
|
|
/// </summary>
|
|
/// <param name="subscription">The subscription expression.</param>
|
|
/// <returns>The XML query payload to pass to <c>SetXmlAlarmQuery</c>.</returns>
|
|
internal static string ComposeXmlAlarmQuery(string subscription)
|
|
{
|
|
string node = Environment.MachineName;
|
|
string provider = "Galaxy";
|
|
string group = string.Empty;
|
|
|
|
if (!string.IsNullOrEmpty(subscription))
|
|
{
|
|
// Strip leading backslashes from "\\<node>\..." form.
|
|
string trimmed = subscription.TrimStart('\\');
|
|
int slash = trimmed.IndexOf('\\');
|
|
if (slash > 0)
|
|
{
|
|
node = trimmed.Substring(0, slash);
|
|
trimmed = trimmed.Substring(slash + 1);
|
|
}
|
|
int bang = trimmed.IndexOf('!');
|
|
if (bang > 0)
|
|
{
|
|
provider = trimmed.Substring(0, bang);
|
|
group = trimmed.Substring(bang + 1);
|
|
}
|
|
else
|
|
{
|
|
provider = trimmed;
|
|
}
|
|
}
|
|
|
|
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
|
sb.Append("<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">");
|
|
sb.Append("<QUERY>");
|
|
sb.Append("<NODE>").Append(node).Append("</NODE>");
|
|
sb.Append("<PROVIDER>").Append(provider).Append("</PROVIDER>");
|
|
if (!string.IsNullOrEmpty(group))
|
|
{
|
|
sb.Append("<GROUP>").Append(group).Append("</GROUP>");
|
|
}
|
|
sb.Append("</QUERY>");
|
|
sb.Append("</QUERIES>");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static VBGUID ToVbGuid(Guid g)
|
|
{
|
|
byte[] bytes = g.ToByteArray();
|
|
// Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4).
|
|
VBGUID vb = new VBGUID
|
|
{
|
|
Data1 = BitConverter.ToInt32(bytes, 0),
|
|
Data2 = BitConverter.ToInt16(bytes, 4),
|
|
Data3 = BitConverter.ToInt16(bytes, 6),
|
|
Data4 = new byte[8],
|
|
};
|
|
Array.Copy(bytes, 8, vb.Data4, 0, 8);
|
|
return vb;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
wwAlarmConsumerClass? clientToDispose;
|
|
wwAlarmConsumerClass? ackClientToDispose;
|
|
lock (syncRoot)
|
|
{
|
|
if (disposed) return;
|
|
disposed = true;
|
|
clientToDispose = client;
|
|
client = null;
|
|
ackClientToDispose = ackClient;
|
|
ackClient = null;
|
|
}
|
|
ReleaseConsumerCom(clientToDispose);
|
|
ReleaseConsumerCom(ackClientToDispose);
|
|
}
|
|
|
|
private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)
|
|
{
|
|
if (consumer is null) return;
|
|
try { consumer.DeregisterConsumer(); } catch { /* swallow */ }
|
|
try { consumer.UninitializeConsumer(); } catch { /* swallow */ }
|
|
if (Marshal.IsComObject(consumer))
|
|
{
|
|
try { Marshal.FinalReleaseComObject(consumer); } catch { /* swallow */ }
|
|
}
|
|
}
|
|
}
|