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; /// /// Production backed by AVEVA's /// standalone WNWRAPCONSUMERLib.wwAlarmConsumerClass COM object /// (CLSID {7AB52E5F-36B2-4A30-AE46-952A746F667C}, hosted by /// C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll). /// /// /// /// Replaces the earlier AlarmClientConsumer built on /// aaAlarmManagedClient.AlarmClient, which crashed in /// GetHighPriAlarm with ArgumentOutOfRangeException /// (FILETIME→DateTime auto-marshaling on AVEVA's sentinel timestamps). /// The wnwrap surface returns the alarm record as a BSTR XML string /// via GetXmlCurrentAlarms2; timestamps arrive as ASCII /// DATE + TIME + GMTOFFSET + DSTADJUST /// fields and never touch the .NET DateTime marshaler. See /// docs/AlarmClientDiscovery.md "Option A — captured" for /// the discovery and the captured payload schema. /// /// /// Threading. The wnwrap CLSID is registered with /// ThreadingModel=Apartment. The consumer must be created /// and operated from an STA thread; the worker's /// runs an STA pump that hosts it. /// The consumer owns no internal timer: every COM call /// (Subscribe, PollOnce, AcknowledgeBy*) must /// be invoked on the STA that created the consumer. Polling cadence /// is driven externally by the worker's STA via /// StaRuntime.InvokeAsync(() => consumer.PollOnce()), which /// keeps every GetXmlCurrentAlarms2 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. /// /// 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; /// /// Floor on the resolved per-fetch cap. Mirrors the gateway-side /// MxGateway:Alarms:MaxAlarmsPerFetch minimum so a value that /// slipped past startup validation still leaves a usable snapshot /// window. /// internal const int MinimumMaxAlarmsPerFetch = 64; /// /// Ceiling on the resolved per-fetch cap. Mirrors the gateway-side /// MxGateway:Alarms:MaxAlarmsPerFetch maximum. The worker is a /// 32-bit process: every poll materializes the whole reply as one BSTR /// and then a full over it, so an unbounded /// cap ( passed startup validation before /// this existed) is an out-of-memory fault on the STA, not a slow poll. /// internal const int MaximumMaxAlarmsPerFetch = 65_536; /// /// Environment variable the gateway's WorkerProcessLauncher /// sets from MxGateway:Alarms:MaxAlarmsPerFetch. A missing, /// unparseable, or out-of-range value falls back to /// — a bad environment value /// must never keep the alarm consumer from starting. /// internal const string MaxAlarmsPerFetchEnvironmentVariableName = "MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH"; /// /// 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. /// private const long TruncationWarningIntervalMilliseconds = 60_000; private readonly object syncRoot = new object(); private readonly Dictionary latestSnapshot = new Dictionary(); 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; /// /// 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 /// StaRuntime.InvokeAsync(() => consumer.PollOnce()) so that /// every COM call stays on the STA that owns the apartment. /// public WnWrapAlarmConsumer() : this(new wwAlarmConsumerClass(), ResolveMaxAlarmsPerFetch()) { } /// /// Test seam / explicit construction. /// /// The COM alarm consumer instance. /// Maximum alarms per fetch call. public WnWrapAlarmConsumer( wwAlarmConsumerClass client, int maxAlarmsPerFetch) { this.client = client ?? throw new ArgumentNullException(nameof(client)); this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0 ? maxAlarmsPerFetch : DefaultMaxAlarmsPerFetch; } /// /// COM-free construction, for exercising the retained-snapshot state /// machine ( / /// / ) on a machine without AVEVA /// installed. throws and /// 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. /// /// Maximum alarms per fetch call. internal WnWrapAlarmConsumer(int maxAlarmsPerFetch) { this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0 ? maxAlarmsPerFetch : DefaultMaxAlarmsPerFetch; } /// /// Resolves the per-fetch cap from the launcher-provided environment /// variable. A missing, unparseable, or out-of-range value falls back /// to 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. /// /// The cap passed to GetXmlCurrentAlarms2. 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; } /// /// Fires once per detected alarm-state transition (raise, acknowledge, /// clear, or new-alarm-already-acked-on-arrival), dispatched from /// on the calling (STA) thread. /// public event EventHandler? AlarmTransitionEmitted; /// 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; } } /// 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); } /// 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); } /// /// /// 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 /// . 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. /// public IReadOnlyList SnapshotActiveAlarms() { if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer)); lock (syncRoot) { List active = new List(); foreach (MxAlarmSnapshotRecord record in latestSnapshot.Values) { if (record.State == MxAlarmStateKind.UnackAlm || record.State == MxAlarmStateKind.AckAlm) { active.Add(record); } } return active; } } /// /// /// Read without the disposed guard /// 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. /// public bool LastSnapshotTruncated { get { lock (syncRoot) { return lastSnapshotTruncated; } } } /// /// Sink for the rate-limited truncated-fetch warning. Defaults to /// , the stream /// WorkerConsoleLogger 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. /// internal Action? TruncationWarningSink { get; set; } /// 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 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 transitions = FoldFetch(next, truncated, out int retainedCount); if (truncated) { WarnTruncatedFetch(fetchedRecordCount, next.Count, retainedCount); } if (transitions.Count == 0) return; EventHandler? handler = AlarmTransitionEmitted; if (handler is null) return; foreach (MxAlarmTransitionEvent transition in transitions) { handler.Invoke(this, transition); } } /// /// 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 /// / /// 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. /// /// The snapshot just parsed from the fetch. /// Whether the fetch hit the per-fetch cap. /// Size of the retained snapshot after the fold. /// The transitions the fetch implies. internal IReadOnlyList FoldFetch( Dictionary next, bool truncated, out int retainedCount) { lock (syncRoot) { IReadOnlyList transitions = ComputeTransitions(latestSnapshot, next); ApplySnapshotUpdate(latestSnapshot, next, truncated); lastSnapshotTruncated = truncated; retainedCount = latestSnapshot.Count; return transitions; } } /// /// Decides whether a fetch that came back holding /// records hit the cap. /// GetXmlCurrentAlarms2 caps its reply at maxAlmCnt 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 ALARM_RECORDS/@COUNT 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 docs/AlarmProbeFindings.md), so the count is deliberately /// not trusted here. Exposed as internal static so the rule is /// unit-testable without the wnwrapConsumer COM object. /// /// ALARM records the reply carried. /// The cap that was passed to the fetch. /// when the reply must be treated as truncated. internal static bool IsTruncatedFetch(int fetchedRecordCount, int maxAlarmsPerFetch) { return fetchedRecordCount >= maxAlarmsPerFetch; } /// /// Folds a freshly fetched snapshot into the retained one. /// /// /// /// A complete fetch is authoritative about absence: /// replacing the snapshot wholesale is exactly what lets a cleared /// alarm drop out of , which is /// the evidence the gateway's reconcile pass turns into a Clear. /// /// /// A truncated fetch is authoritative about presence /// 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. /// /// /// The retained snapshot, updated in place. /// The snapshot just parsed from the fetch. /// Whether the fetch hit the per-fetch cap. internal static void ApplySnapshotUpdate( Dictionary snapshot, Dictionary 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 kv in next) { snapshot[kv.Key] = kv.Value; } } /// /// Emits the truncated-fetch warning at most once per /// , counting every /// truncated poll in between so the operator can tell a one-off burst /// from a galaxy permanently parked above the cap. /// /// ALARM records the capped reply carried. /// Records that survived GUID parsing. /// Snapshot size after the merge. 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= event= 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 sink = TruncationWarningSink ?? Console.Error.WriteLine; sink(message); } /// /// Rate-limit decision for the truncated-fetch warning: emit only when /// a full 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 internal static /// so the throttle is unit-testable without the wnwrapConsumer COM /// object. /// /// Consumer-lifetime clock reading for this poll. /// Clock reading when the last warning was emitted. /// when this poll may emit a warning. internal static bool ShouldWarnTruncation( long elapsedMilliseconds, long lastWarningMilliseconds) { return elapsedMilliseconds - lastWarningMilliseconds >= TruncationWarningIntervalMilliseconds; } /// /// Pure snapshot-to-transitions diff. Compares the previous polled /// snapshot to the next snapshot and produces one /// per state change. Used by /// after a successful /// GetXmlCurrentAlarms2 call; exposed as internal static /// so the diff rules can be unit-tested without driving the /// wnwrapConsumer COM object. /// /// /// Rules: /// /// A GUID present in but not in produces a transition with as the previous state — first sighting. /// A GUID present in both with the same produces no transition. /// A GUID present in both with a different produces a transition carrying the prior state. /// A GUID present in but absent from produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them. /// /// /// 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 , which reads the /// snapshot maintains from these same /// dictionaries. That is why the truncation guard lives in /// '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 straight through /// stays correct for the alarms it does carry — first sightings /// and state changes are computed from presence alone. /// /// /// Every rule above assumes the GUID identifies the alarm /// instance 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 /// (docs/AlarmClientDiscovery.md); the acknowledge leg and /// re-raise-after-clear are assumed, not observed, because the dev /// rig's alarm attributes reject unauthenticated writes — see /// docs/AlarmProbeFindings.md. /// /// /// The snapshot from the previous poll (or empty on first call). /// The snapshot just parsed from GetXmlCurrentAlarms2. /// One transition per state change in . internal static IReadOnlyList ComputeTransitions( Dictionary previous, Dictionary next) { if (previous is null) throw new ArgumentNullException(nameof(previous)); if (next is null) throw new ArgumentNullException(nameof(next)); List transitions = new List(); foreach (KeyValuePair 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; } /// /// Parse the XML payload returned by GetXmlCurrentAlarms2 /// into a GUID-keyed dictionary. Records with malformed GUIDs are /// silently dropped (no fault is recorded — the next poll will /// resync). /// /// The XML snapshot payload. /// The parsed alarm snapshot records, keyed by alarm GUID. public static Dictionary ParseSnapshotXml(string xml) { return ParseSnapshotXml(xml, out _); } /// /// plus the raw /// ALARM-element count, which 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. /// /// The XML snapshot payload. /// Number of ALARM elements in the payload. /// The parsed alarm snapshot records, keyed by alarm GUID. internal static Dictionary ParseSnapshotXml( string xml, out int alarmRecordCount) { Dictionary records = new Dictionary(); 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 () // 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; } /// /// wnwrap's XML GUID field is a 32-char hex string with no /// dashes (e.g. "BCC4705395424D65BDAABCDEA6A32A73"). Convert /// to 's canonical 8-4-4-4-12 layout. /// /// The 32-character hex GUID string. /// The parsed GUID, or Empty if parsing fails. /// if was successfully parsed; otherwise, . 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); } /// /// Compose the XML payload SetXmlAlarmQuery expects from a /// canonical subscription expression /// (\\<machine>\Galaxy!<area>). The wnwrap /// consumer mangles the round-trip but evidently still needs the /// call — without it GetXmlCurrentAlarms2 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. /// /// The subscription expression. /// The XML query payload to pass to SetXmlAlarmQuery. 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 "\\\..." 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(""); sb.Append(""); sb.Append("").Append(node).Append(""); sb.Append("").Append(provider).Append(""); if (!string.IsNullOrEmpty(group)) { sb.Append("").Append(group).Append(""); } sb.Append(""); sb.Append(""); 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; } /// 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 */ } } } }