rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx

Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.

External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
  MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths

Also fixes two tests that were not rename-related but became visible
while validating the rename:

- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
  gateway service correctly maps to RpcException(Cancelled) per gRPC
  convention was being misclassified as a stream fault. Added a sibling
  catch on RpcException with StatusCode.Cancelled.

- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
  and made it accept either a .git marker OR a .sln/.slnx next to src/
  so the worker-exe walker works in non-git working copies.

clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.

Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
  Tests: 472/472 pass
  Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
  IntegrationTests: 18/18 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 16:22:23 -04:00
parent 867bf18116
commit dc9c0c950c
491 changed files with 32854 additions and 8414 deletions
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Per-session owner of the worker's alarm-side state. Lazy-creates an
/// <see cref="AlarmDispatcher"/> (with a wnwrap-backed
/// <see cref="WnWrapAlarmConsumer"/> by default) on the first
/// <see cref="Subscribe"/> call, then routes
/// <see cref="Acknowledge"/> / <see cref="QueryActive"/> /
/// <see cref="Unsubscribe"/> through the same instance for the
/// session's lifetime.
/// </summary>
/// <remarks>
/// <para>
/// Construction is dependency-injectable: the consumer factory
/// (default <c>() =&gt; new WnWrapAlarmConsumer()</c>) lets tests
/// substitute a fake without touching AVEVA COM. The event queue
/// is supplied by the owning <see cref="MxAccessStaSession"/> so
/// the alarm-side proto events land on the same queue the worker
/// already drains for IPC dispatch.
/// </para>
/// <para>
/// Threading: invoked from <see cref="MxAccessCommandExecutor"/>
/// which runs on the STA. The wnwrap consumer owns no internal
/// timer — the worker's STA drives <see cref="PollOnce"/> via
/// <c>StaRuntime.InvokeAsync</c>, so the consumer's transition
/// events fire on the same STA. The
/// <see cref="AlarmDispatcher"/>'s event handler hands transitions
/// into the thread-safe <see cref="MxAccessEventQueue"/>.
/// </para>
/// </remarks>
public sealed class AlarmCommandHandler : IAlarmCommandHandler
{
private readonly MxAccessEventQueue eventQueue;
private readonly Func<IMxAccessAlarmConsumer> consumerFactory;
private readonly Action? threadAffinityCheck;
private readonly object syncRoot = new object();
private AlarmDispatcher? dispatcher;
private bool disposed;
public AlarmCommandHandler(MxAccessEventQueue eventQueue)
: this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null)
{
}
/// <summary>Test seam — inject a custom consumer factory.</summary>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory)
: this(eventQueue, consumerFactory, threadAffinityCheck: null)
{
}
/// <summary>
/// Worker-024: production constructor that also injects an
/// STA-affinity guard. <paramref name="threadAffinityCheck"/> is
/// invoked at the entry of every method that touches the underlying
/// <see cref="IMxAccessAlarmConsumer"/> (or the wnwrap COM object
/// through it) — <see cref="Subscribe"/>, <see cref="Unsubscribe"/>,
/// <see cref="Acknowledge"/>, <see cref="AcknowledgeByName"/>,
/// <see cref="QueryActive"/>, <see cref="PollOnce"/> — so an
/// off-STA call raises a programming-error diagnostic instead of
/// deadlocking on cross-apartment marshaling to the
/// <c>ThreadingModel=Apartment</c> wnwrap CLSID. The guard is
/// optional: tests that already drive the handler on a single
/// thread can pass <c>null</c>.
/// </summary>
public AlarmCommandHandler(
MxAccessEventQueue eventQueue,
Func<IMxAccessAlarmConsumer> consumerFactory,
Action? threadAffinityCheck)
{
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
this.threadAffinityCheck = threadAffinityCheck;
}
public bool IsSubscribed
{
get { lock (syncRoot) return dispatcher is not null; }
}
/// <inheritdoc />
public void Subscribe(string subscription, string sessionId)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
threadAffinityCheck?.Invoke();
lock (syncRoot)
{
if (dispatcher is not null)
{
throw new InvalidOperationException(
"AlarmCommandHandler already has an active subscription; " +
"call Unsubscribe before issuing another SubscribeAlarms command.");
}
IMxAccessAlarmConsumer consumer = consumerFactory()
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(
eventQueue, new MxAccessEventMapper());
dispatcher = new AlarmDispatcher(consumer, sink, sessionId ?? string.Empty);
try
{
dispatcher.Subscribe(subscription);
}
catch
{
try { dispatcher.Dispose(); } catch { /* swallow */ }
dispatcher = null;
throw;
}
}
}
/// <inheritdoc />
public void Unsubscribe()
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? toDispose;
lock (syncRoot)
{
toDispose = dispatcher;
dispatcher = null;
}
toDispose?.Dispose();
}
/// <inheritdoc />
public int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
return d.Acknowledge(
alarmGuid,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
}
/// <inheritdoc />
public int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
return d.AcknowledgeByName(
alarmName ?? string.Empty,
providerName ?? string.Empty,
groupName ?? string.Empty,
comment ?? string.Empty,
operatorUser ?? string.Empty,
operatorNode ?? string.Empty,
operatorDomain ?? string.Empty,
operatorFullName ?? string.Empty);
}
/// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d = GetDispatcherOrThrow();
IReadOnlyList<ActiveAlarmSnapshot> all = d.SnapshotActiveAlarms();
if (string.IsNullOrEmpty(alarmFilterPrefix)) return all;
List<ActiveAlarmSnapshot> filtered = new List<ActiveAlarmSnapshot>(all.Count);
foreach (ActiveAlarmSnapshot snap in all)
{
if (snap.AlarmFullReference.StartsWith(alarmFilterPrefix!, StringComparison.Ordinal))
{
filtered.Add(snap);
}
}
return filtered;
}
/// <inheritdoc />
public void PollOnce()
{
threadAffinityCheck?.Invoke();
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
// No-op when not yet subscribed or already disposed.
d?.PollOnce();
}
private AlarmDispatcher GetDispatcherOrThrow()
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
if (d is null)
{
throw new InvalidOperationException(
"AlarmCommandHandler has no active subscription; " +
"call SubscribeAlarms before issuing alarm-related commands.");
}
return d;
}
/// <inheritdoc />
public void Dispose()
{
if (disposed) return;
disposed = true;
Unsubscribe();
}
}
@@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// In-process dispatcher that owns the lifetime of an
/// <see cref="IMxAccessAlarmConsumer"/> + <see cref="MxAccessAlarmEventSink"/>
/// pair, and wires the consumer's <c>AlarmTransitionEmitted</c> stream
/// onto the sink's <c>EnqueueTransition</c> path so transitions land on
/// the worker's <see cref="MxAccessEventQueue"/> as proto
/// <see cref="OnAlarmTransitionEvent"/> messages ready for IPC dispatch.
/// </summary>
/// <remarks>
/// <para>
/// The dispatcher carries the consumer→sink→queue pipeline. The
/// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> /
/// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c>
/// through <see cref="AlarmCommandHandler"/>, which owns one
/// dispatcher per session.
/// </para>
/// <para>
/// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal
/// timer — the worker's STA drives polling via
/// <c>StaRuntime.InvokeAsync(() =&gt; PollOnce())</c>, so the
/// consumer's <c>AlarmTransitionEmitted</c> event fires on the STA.
/// The dispatcher is purely a pass-through, so it inherits that
/// thread. Fan-out into <c>EnqueueTransition</c> uses the
/// thread-safe <see cref="MxAccessEventQueue.Enqueue"/>.
/// </para>
/// </remarks>
public sealed class AlarmDispatcher : IDisposable
{
private readonly IMxAccessAlarmConsumer consumer;
private readonly MxAccessAlarmEventSink sink;
private readonly string sessionId;
private readonly EventHandler<MxAlarmTransitionEvent> handler;
private bool disposed;
public AlarmDispatcher(
IMxAccessAlarmConsumer consumer,
MxAccessAlarmEventSink sink,
string sessionId)
{
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
this.sessionId = sessionId ?? string.Empty;
// Sink.Attach is the seam that propagates the session id onto the
// proto SessionId field of every emitted MxEvent. Pass the consumer
// as the "associated COM object" — sink ignores the object reference
// for the alarm path, but the existing IMxAccessEventSink contract
// requires a non-null first arg.
this.sink.Attach(this.consumer, this.sessionId);
this.handler = OnTransition;
consumer.AlarmTransitionEmitted += handler;
}
/// <summary>
/// Begin polling the configured AVEVA alarm provider for
/// transitions. The supplied subscription expression follows the
/// canonical <c>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</c> format.
/// </summary>
public void Subscribe(string subscription)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
consumer.Subscribe(subscription);
}
/// <summary>
/// Forward an <c>AcknowledgeAlarm</c> request to the underlying
/// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native
/// status code (0 = success).
/// </summary>
public int Acknowledge(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName)
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
return consumer.AcknowledgeByGuid(
alarmGuid,
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
}
/// <summary>
/// Acknowledge an alarm by its (name, provider, group) tuple.
/// Routes to the consumer's <c>AcknowledgeByName</c> path which
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>.
/// </summary>
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(AlarmDispatcher));
return consumer.AcknowledgeByName(
alarmName,
providerName,
groupName,
ackComment,
ackOperatorName,
ackOperatorNode,
ackOperatorDomain,
ackOperatorFullName);
}
/// <summary>
/// Drives a single synchronous poll of the underlying consumer.
/// Must be called on the STA thread that owns the wnwrap COM object.
/// No-op if the dispatcher has been disposed.
/// </summary>
public void PollOnce()
{
if (disposed) return;
consumer.PollOnce();
}
/// <summary>
/// Snapshot the currently-active alarm set as
/// <see cref="ActiveAlarmSnapshot"/> protos for the
/// <c>QueryActiveAlarms</c> RPC's ConditionRefresh stream.
/// </summary>
public IReadOnlyList<ActiveAlarmSnapshot> SnapshotActiveAlarms()
{
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
IReadOnlyList<MxAlarmSnapshotRecord> records = consumer.SnapshotActiveAlarms();
if (records.Count == 0) return Array.Empty<ActiveAlarmSnapshot>();
List<ActiveAlarmSnapshot> snapshots = new List<ActiveAlarmSnapshot>(records.Count);
foreach (MxAlarmSnapshotRecord record in records)
{
snapshots.Add(MapToSnapshot(record));
}
return snapshots;
}
private void OnTransition(object? sender, MxAlarmTransitionEvent transition)
{
if (disposed) return;
if (transition is null) return;
MxAlarmSnapshotRecord record = transition.Record;
AlarmTransitionKind kind = AlarmRecordTransitionMapper.MapTransition(
transition.PreviousState, record.State);
if (kind == AlarmTransitionKind.Unspecified) return;
string fullReference = AlarmRecordTransitionMapper.ComposeFullReference(
record.ProviderName, record.Group, record.TagName);
sink.EnqueueTransition(
alarmFullReference: fullReference,
sourceObjectReference: record.TagName,
alarmTypeName: record.Type,
transitionKind: kind,
severity: record.Priority,
originalRaiseTimestampUtc: null,
transitionTimestampUtc: record.TransitionTimestampUtc,
operatorUser: record.OperatorName,
operatorComment: record.AlarmComment,
category: record.Group,
description: string.Empty);
}
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record)
{
ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot
{
AlarmFullReference = AlarmRecordTransitionMapper.ComposeFullReference(
record.ProviderName, record.Group, record.TagName),
SourceObjectReference = record.TagName,
AlarmTypeName = record.Type,
CurrentState = MapConditionState(record.State),
Severity = record.Priority,
OperatorUser = record.OperatorName,
OperatorComment = record.AlarmComment,
Category = record.Group,
Description = string.Empty,
};
if (record.TransitionTimestampUtc != DateTime.MinValue)
{
snapshot.LastTransitionTimestamp =
Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
DateTime.SpecifyKind(record.TransitionTimestampUtc, DateTimeKind.Utc));
}
return snapshot;
}
private static AlarmConditionState MapConditionState(MxAlarmStateKind state)
{
// The proto's AlarmConditionState only distinguishes Active /
// ActiveAcked / Inactive — both Rtn states collapse to Inactive
// (the ack-vs-unack distinction on a cleared alarm is not exposed
// through OPC UA's Part 9 condition state model anyway).
return state switch
{
MxAlarmStateKind.UnackAlm => AlarmConditionState.Active,
MxAlarmStateKind.AckAlm => AlarmConditionState.ActiveAcked,
MxAlarmStateKind.UnackRtn => AlarmConditionState.Inactive,
MxAlarmStateKind.AckRtn => AlarmConditionState.Inactive,
_ => AlarmConditionState.Unspecified,
};
}
public string SessionId => sessionId;
public void Dispose()
{
if (disposed) return;
disposed = true;
try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ }
try { sink.Detach(); } catch { /* swallow */ }
try { consumer.Dispose(); } catch { /* swallow */ }
}
}
@@ -0,0 +1,183 @@
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>
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>
/// </summary>
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>
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>
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 (UTC-4) — 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);
}
}
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Per-session interface routing the worker's alarm IPC commands —
/// <c>SubscribeAlarmsCommand</c>, <c>AcknowledgeAlarmCommand</c>,
/// <c>QueryActiveAlarmsCommand</c>, <c>UnsubscribeAlarmsCommand</c> —
/// to the underlying <see cref="AlarmDispatcher"/>. Production binding
/// is <see cref="AlarmCommandHandler"/>; tests substitute a fake.
/// </summary>
public interface IAlarmCommandHandler : IDisposable
{
/// <summary>Begin a subscription against the supplied AVEVA alarm-provider expression.</summary>
void Subscribe(string subscription, string sessionId);
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
void Unsubscribe();
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary>
int Acknowledge(
Guid alarmGuid,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <summary>
/// Acknowledge a single alarm by (name, provider, group) — used when
/// the caller has the human-readable reference but not the GUID.
/// </summary>
int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string comment,
string operatorUser,
string operatorNode,
string operatorDomain,
string operatorFullName);
/// <summary>
/// Snapshot the currently-active alarm set, optionally scoped to a
/// prefix matched against <c>AlarmFullReference</c>.
/// </summary>
IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix);
/// <summary>
/// Drives a single poll of the underlying alarm consumer on the
/// caller's thread. This is a no-op when there is no active
/// subscription. In production the caller is the worker's STA
/// (marshalled via <c>StaRuntime.InvokeAsync</c>), which satisfies
/// the <c>ThreadingModel=Apartment</c> requirement of
/// <c>wwAlarmConsumerClass</c>.
/// </summary>
void PollOnce();
}
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Abstraction over an AVEVA alarm-consumer COM library. The production
/// implementation (<see cref="WnWrapAlarmConsumer"/>) wraps
/// <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> from
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>;
/// tests substitute a fake to drive transition events without a live
/// Galaxy.
/// </summary>
/// <remarks>
/// <para>
/// The receive surface is poll-based: the production consumer
/// periodically calls <c>GetXmlCurrentAlarms2</c>, parses the
/// returned XML payload, diffs against the previous snapshot keyed
/// by alarm GUID, and raises <see cref="AlarmTransitionEmitted"/>
/// once per state change. This bypasses the FILETIME marshaling
/// crash in <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>
/// (see <c>docs/AlarmClientDiscovery.md</c>) — XML strings carry
/// timestamps as ASCII fields, no DateTime auto-conversion happens
/// on the .NET interop boundary.
/// </para>
/// </remarks>
public interface IMxAccessAlarmConsumer : IDisposable
{
/// <summary>
/// Fires once per detected alarm-state transition (raise, acknowledge,
/// clear, or new-alarm-already-acked-on-arrival). Subscribers are
/// expected to translate the record into the proto family
/// <c>OnAlarmTransition</c> and enqueue it. Fired on the consumer's
/// polling thread (the worker's STA in production); subscribers that
/// need a different thread must marshal back themselves.
/// </summary>
event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
/// <summary>
/// Initializes the AVEVA alarm-client connection, registers as a
/// consumer, and subscribes to the supplied alarm-provider expression.
/// Subscription string follows AVEVA's canonical format:
/// <c>\\&lt;node&gt;\Galaxy!&lt;area&gt;</c>. The literal "Galaxy" is
/// the provider name (regardless of the configured Galaxy database
/// name). Subscribe does not start any polling of its own; the caller
/// drives polls explicitly via <see cref="PollOnce"/>.
/// </summary>
void Subscribe(string subscription);
/// <summary>
/// Acknowledges a single alarm with full operator-identity fidelity.
/// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator
/// user / node / domain / full-name and the comment land atomically
/// with the ack transition in the alarm-history log.
/// </summary>
int AcknowledgeByGuid(
Guid alarmGuid,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <summary>
/// Acknowledge a single alarm by its (name, provider, group) tuple.
/// Reaches AVEVA's <c>AlarmAckByName</c> on
/// <c>wwAlarmConsumerClass</c>; same alarm-history outcome as
/// <see cref="AcknowledgeByGuid"/>, used when the caller has the
/// human-readable reference but not the canonical GUID.
/// </summary>
int AcknowledgeByName(
string alarmName,
string providerName,
string groupName,
string ackComment,
string ackOperatorName,
string ackOperatorNode,
string ackOperatorDomain,
string ackOperatorFullName);
/// <summary>
/// Returns the consumer's most recently parsed snapshot of currently
/// active alarms. Used by the gateway's QueryActiveAlarms (PR A.7)
/// ConditionRefresh path — operator clients call this after reconnect
/// to seed local Part 9 state.
/// </summary>
IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms();
/// <summary>
/// Drives a single synchronous poll of the underlying alarm source.
/// The production consumer owns no internal timer; the worker's STA
/// drives polls via <c>StaRuntime.InvokeAsync</c>, satisfying the
/// <c>ThreadingModel=Apartment</c> requirement of
/// <c>wwAlarmConsumerClass</c>. Fake implementations should no-op.
/// This method must be invoked on the thread that created the consumer
/// (the worker's STA in production).
/// </summary>
void PollOnce();
}
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public interface IMxAccessComObjectFactory
{
/// <summary>Creates an MXAccess COM object instance.</summary>
/// <returns>The created COM object.</returns>
object Create();
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public interface IMxAccessEventSink
{
/// <summary>Attaches the event sink to an MXAccess COM object.</summary>
/// <param name="mxAccessComObject">The MXAccess COM object.</param>
/// <param name="sessionId">The session ID.</param>
void Attach(
object mxAccessComObject,
string sessionId);
/// <summary>Detaches the event sink from the COM object.</summary>
void Detach();
}
@@ -0,0 +1,111 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public interface IMxAccessServer
{
/// <summary>Registers a client and returns a server handle.</summary>
/// <param name="clientName">Name of the client requesting registration.</param>
/// <returns>Server handle for subsequent operations.</returns>
int Register(string clientName);
/// <summary>Unregisters a server handle.</summary>
/// <param name="serverHandle">Server handle to unregister.</param>
void Unregister(int serverHandle);
/// <summary>Adds an item to a server and returns an item handle.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemDefinition">Item definition string.</param>
/// <returns>Item handle for the added item.</returns>
int AddItem(
int serverHandle,
string itemDefinition);
/// <summary>Adds an item with context to a server and returns an item handle.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemDefinition">Item definition string.</param>
/// <param name="itemContext">Item context string.</param>
/// <returns>Item handle for the added item.</returns>
int AddItem2(
int serverHandle,
string itemDefinition,
string itemContext);
/// <summary>Removes an item from a server.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to remove.</param>
void RemoveItem(
int serverHandle,
int itemHandle);
/// <summary>Subscribes to change notifications for an item.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to subscribe to.</param>
void Advise(
int serverHandle,
int itemHandle);
/// <summary>Unsubscribes from change notifications for an item.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to unsubscribe from.</param>
void UnAdvise(
int serverHandle,
int itemHandle);
/// <summary>Subscribes to supervisory change notifications for an item.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to subscribe to.</param>
void AdviseSupervisory(
int serverHandle,
int itemHandle);
/// <summary>Writes a value to an item.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
void Write(
int serverHandle,
int itemHandle,
object? value,
int userId);
/// <summary>Writes a value with an explicit source timestamp to an item.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
void Write2(
int serverHandle,
int itemHandle,
object? value,
object? timestamp,
int userId);
/// <summary>Performs a secured/verified write to an item.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
void WriteSecured(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value);
/// <summary>Performs a secured/verified write with an explicit source timestamp.</summary>
/// <param name="serverHandle">Server handle identifying the registration.</param>
/// <param name="itemHandle">Item handle to write to.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
void WriteSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value,
object? timestamp);
}
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Manages the runtime session between the worker and the MXAccess COM instance on an STA thread.
/// </summary>
public interface IWorkerRuntimeSession : IDisposable
{
/// <summary>
/// Starts the session, creates the MXAccess COM object, and returns ready metadata.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="workerProcessId">ID of the worker process.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task returning worker readiness metadata.</returns>
Task<WorkerReady> StartAsync(
string sessionId,
int workerProcessId,
CancellationToken cancellationToken = default);
/// <summary>
/// Dispatches an STA command to the MXAccess runtime and returns the reply.
/// </summary>
/// <param name="command">STA command to execute on the STA thread.</param>
/// <returns>Asynchronous task returning the command reply.</returns>
Task<MxCommandReply> DispatchAsync(StaCommand command);
/// <summary>
/// Captures a heartbeat snapshot of the runtime state.
/// </summary>
WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat();
/// <summary>
/// Drains up to the specified number of pending events from the queue.
/// </summary>
/// <param name="maxEvents">Maximum number of events to drain.</param>
/// <returns>List of drained events.</returns>
IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents);
/// <summary>
/// Drains a pending fault from the queue, if any.
/// </summary>
WorkerFault? DrainFault();
/// <summary>
/// Cancels a pending command by correlation ID.
/// </summary>
/// <param name="correlationId">Correlation ID of the command to cancel.</param>
/// <returns>True if the command was found and cancelled; otherwise, false.</returns>
bool CancelCommand(string correlationId);
/// <summary>
/// Requests a graceful shutdown of the session.
/// </summary>
void RequestShutdown();
/// <summary>
/// Shuts down the session gracefully within the specified timeout.
/// </summary>
/// <param name="timeout">Maximum time to allow for graceful shutdown.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task returning the shutdown result.</returns>
Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
TimeSpan timeout,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public enum MxAccessAdviceKind
{
Plain = 1,
Supervisory = 2,
}
@@ -0,0 +1,120 @@
using System;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Sink for native MxAccess alarm transitions. Bridges
/// <see cref="WnWrapAlarmConsumer"/> to the worker's event queue,
/// producing <see cref="OnAlarmTransitionEvent"/> messages via
/// <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="AlarmDispatcher"/> owns the wire-up: it constructs the
/// consumer/sink pair, calls <see cref="Attach"/> to propagate the
/// session id, and subscribes the consumer's
/// <see cref="IMxAccessAlarmConsumer.AlarmTransitionEmitted"/> event
/// so each decoded transition reaches <see cref="EnqueueTransition"/>.
/// The <see cref="Attach"/> method here carries only the session id —
/// the alarm path needs no COM-event subscription of its own because
/// the consumer already polls and raises transition events. The
/// captured payload schema is described in
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured".
/// </para>
/// </remarks>
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
{
private readonly MxAccessEventMapper eventMapper;
private readonly MxAccessEventQueue eventQueue;
private string sessionId = string.Empty;
private bool attached;
public MxAccessAlarmEventSink()
: this(new MxAccessEventQueue(), new MxAccessEventMapper())
{
}
public MxAccessAlarmEventSink(
MxAccessEventQueue eventQueue,
MxAccessEventMapper eventMapper)
{
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper));
}
/// <inheritdoc />
public void Attach(object mxAccessComObject, string sessionId)
{
if (mxAccessComObject is null) throw new ArgumentNullException(nameof(mxAccessComObject));
this.sessionId = sessionId ?? string.Empty;
// The alarm path needs no COM-event subscription here: the wnwrap
// consumer is polled by the worker's STA and raises transition events
// that AlarmDispatcher routes into EnqueueTransition. Attach only
// records the session id stamped onto every emitted MxEvent.
attached = true;
}
/// <inheritdoc />
public void Detach()
{
if (!attached) return;
attached = false;
sessionId = string.Empty;
}
/// <summary>
/// Enqueues a decoded alarm transition. The COM-side delegate registered
/// in <see cref="Attach"/> calls this method once it pulls the alarm
/// fields out of the MxAccess event payload. Exposed internal so unit
/// tests can drive the proto build path without a real COM event
/// source.
/// </summary>
internal void EnqueueTransition(
string alarmFullReference,
string sourceObjectReference,
string alarmTypeName,
AlarmTransitionKind transitionKind,
int severity,
DateTime? originalRaiseTimestampUtc,
DateTime transitionTimestampUtc,
string operatorUser,
string operatorComment,
string category,
string description)
{
try
{
MxEvent mxEvent = eventMapper.CreateOnAlarmTransition(
sessionId,
alarmFullReference,
sourceObjectReference,
alarmTypeName,
transitionKind,
severity,
originalRaiseTimestampUtc,
transitionTimestampUtc,
operatorUser,
operatorComment,
category,
description,
statuses: null);
eventQueue.Enqueue(mxEvent);
}
catch (Exception exception)
{
eventQueue.RecordFault(new WorkerFault
{
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
ExceptionType = exception.GetType().FullName ?? string.Empty,
DiagnosticMessage = $"{exception.GetType().FullName}: HRESULT 0x{unchecked((uint)exception.HResult):X8}",
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = "MXAccess alarm event conversion failed.",
},
});
}
}
}
@@ -0,0 +1,254 @@
using System;
using ArchestrA.MxAccess;
using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Sink for MXAccess COM events that converts them to protobuf format.</summary>
public sealed class MxAccessBaseEventSink : IMxAccessEventSink
{
private readonly MxAccessEventMapper eventMapper;
private readonly MxAccessEventQueue eventQueue;
private readonly MxAccessValueCache valueCache;
private LMXProxyServerClass? server;
private string sessionId = string.Empty;
/// <summary>Initializes a new instance of the MxAccessBaseEventSink class with a default queue.</summary>
public MxAccessBaseEventSink()
: this(new MxAccessEventQueue())
{
}
/// <summary>Initializes a new instance of the MxAccessBaseEventSink class with a provided queue.</summary>
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
public MxAccessBaseEventSink(MxAccessEventQueue eventQueue)
: this(eventQueue, new MxAccessEventMapper(), new MxAccessValueCache())
{
}
/// <summary>Initializes a new instance of the MxAccessBaseEventSink class with provided queue and mapper.</summary>
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
/// <param name="eventMapper">Converter for MXAccess events to protobuf format.</param>
public MxAccessBaseEventSink(
MxAccessEventQueue eventQueue,
MxAccessEventMapper eventMapper)
: this(eventQueue, eventMapper, new MxAccessValueCache())
{
}
/// <summary>
/// Initializes a new instance of the MxAccessBaseEventSink class with
/// provided queue, mapper, and a shared value cache. The cache is
/// populated from every successful <c>OnDataChange</c> dispatch so the
/// worker's ReadBulk executor can satisfy a "current value" request
/// from an already-advised tag without touching the subscription.
/// </summary>
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
/// <param name="eventMapper">Converter for MXAccess events to protobuf format.</param>
/// <param name="valueCache">Per-session last-value cache shared with the MxAccessSession.</param>
public MxAccessBaseEventSink(
MxAccessEventQueue eventQueue,
MxAccessEventMapper eventMapper,
MxAccessValueCache valueCache)
{
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper));
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
}
/// <summary>
/// The last-value cache populated by this sink. Exposed so the
/// MxAccessSession can share the same instance for ReadBulk lookups.
/// </summary>
public MxAccessValueCache ValueCache => valueCache;
/// <inheritdoc />
public void Attach(
object mxAccessComObject,
string sessionId)
{
this.sessionId = sessionId ?? string.Empty;
server = (LMXProxyServerClass)mxAccessComObject;
server.OnDataChange += OnDataChange;
server.OnWriteComplete += OnWriteComplete;
server.OperationComplete += OperationComplete;
server.OnBufferedDataChange += OnBufferedDataChange;
}
/// <inheritdoc />
public void Detach()
{
if (server is null)
{
return;
}
server.OnDataChange -= OnDataChange;
server.OnWriteComplete -= OnWriteComplete;
server.OperationComplete -= OperationComplete;
server.OnBufferedDataChange -= OnBufferedDataChange;
server = null;
sessionId = string.Empty;
}
/// <summary>
/// Handles the MXAccess <c>OnDataChange</c> COM event: converts the
/// event arguments to a protobuf <see cref="Proto.MxEvent"/> and enqueues
/// it. Subscribed to the COM object's event in <see cref="Attach"/>.
/// Exposed <c>internal</c> so unit tests can drive the integrated
/// sink → mapper → queue path without a live MXAccess COM event source.
/// </summary>
internal void OnDataChange(
int hLMXServerHandle,
int phItemHandle,
object pvItemValue,
int pwItemQuality,
object pftItemTimeStamp,
ref MXSTATUS_PROXY[] pVars)
{
MXSTATUS_PROXY[] statuses = pVars;
// Build the protobuf event once, enqueue it for the outbound stream, and
// also publish it into the per-session value cache so ReadBulk can serve
// it as a "current value" without re-advising. The cache update is the
// ONLY new side effect — fail-fast on conversion still drops the event
// through the same EnqueueEvent path as before.
EnqueueEvent(
() => eventMapper.CreateOnDataChange(
sessionId,
hLMXServerHandle,
phItemHandle,
pvItemValue,
pwItemQuality,
pftItemTimeStamp,
statuses),
mxEvent => valueCache.Set(hLMXServerHandle, phItemHandle, mxEvent));
}
/// <summary>
/// Handles the MXAccess <c>OnWriteComplete</c> COM event. Exposed
/// <c>internal</c> as a unit-test seam; see <see cref="OnDataChange"/>.
/// </summary>
internal void OnWriteComplete(
int hLMXServerHandle,
int phItemHandle,
ref MXSTATUS_PROXY[] pVars)
{
MXSTATUS_PROXY[] statuses = pVars;
EnqueueEvent(() => eventMapper.CreateOnWriteComplete(
sessionId,
hLMXServerHandle,
phItemHandle,
statuses));
}
/// <summary>
/// Handles the MXAccess <c>OperationComplete</c> COM event. Exposed
/// <c>internal</c> as a unit-test seam; see <see cref="OnDataChange"/>.
/// </summary>
internal void OperationComplete(
int hLMXServerHandle,
int phItemHandle,
ref MXSTATUS_PROXY[] pVars)
{
MXSTATUS_PROXY[] statuses = pVars;
EnqueueEvent(() => eventMapper.CreateOperationComplete(
sessionId,
hLMXServerHandle,
phItemHandle,
statuses));
}
/// <summary>
/// Handles the MXAccess <c>OnBufferedDataChange</c> COM event. Exposed
/// <c>internal</c> as a unit-test seam; see <see cref="OnDataChange"/>.
/// </summary>
internal void OnBufferedDataChange(
int hLMXServerHandle,
int phItemHandle,
MxDataType dtDataType,
object pvItemValue,
object pwItemQuality,
object pftItemTimeStamp,
ref MXSTATUS_PROXY[] pVars)
{
MXSTATUS_PROXY[] statuses = pVars;
EnqueueEvent(() => eventMapper.CreateOnBufferedDataChange(
sessionId,
hLMXServerHandle,
phItemHandle,
(int)dtDataType,
pvItemValue,
pwItemQuality,
pftItemTimeStamp,
statuses));
}
private void EnqueueEvent(Func<Proto.MxEvent> createEvent)
{
EnqueueEvent(createEvent, postPublish: null);
}
private void EnqueueEvent(Func<Proto.MxEvent> createEvent, Action<Proto.MxEvent>? postPublish)
{
Proto.MxEvent mxEvent;
try
{
mxEvent = createEvent();
}
catch (Exception exception)
{
eventQueue.RecordFault(CreateEventConversionFault(exception));
return;
}
try
{
eventQueue.Enqueue(mxEvent);
}
catch (Exception exception)
{
// Two distinct failures land here, both intentionally fail-fast:
// - A conversion failure from createEvent() — recorded here as an
// MxaccessEventConversionFailed fault.
// - An MxAccessEventQueueOverflowException from Enqueue when the
// queue is at capacity. Per the fail-fast backpressure design
// (docs/DesignDecisions.md) the event is dropped and the queue
// has *already* self-recorded a QueueOverflow fault. Because
// MxAccessEventQueue.RecordFault keeps only the first fault,
// this catch's RecordFault call is then a deliberate near
// no-op rather than a second, conflicting fault.
eventQueue.RecordFault(CreateEventConversionFault(exception));
return;
}
// Only publish to caches/observers after the event has cleared the
// queue, so a queue overflow does not leak a "fresher" cached value
// than what was actually shipped to the gateway.
if (postPublish is not null)
{
try
{
postPublish(mxEvent);
}
catch (Exception exception)
{
eventQueue.RecordFault(CreateEventConversionFault(exception));
}
}
}
private Proto.WorkerFault CreateEventConversionFault(Exception exception)
{
return new Proto.WorkerFault
{
Category = Proto.WorkerFaultCategory.MxaccessEventConversionFailed,
ExceptionType = exception.GetType().FullName ?? string.Empty,
DiagnosticMessage = $"{exception.GetType().FullName}: HRESULT 0x{unchecked((uint)exception.HResult):X8}",
ProtocolStatus = new Proto.ProtocolStatus
{
Code = Proto.ProtocolStatusCode.MxaccessFailure,
Message = "MXAccess event conversion failed.",
},
};
}
}
@@ -0,0 +1,13 @@
using ArchestrA.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Factory for creating MXAccess COM objects on the STA thread.</summary>
public sealed class MxAccessComObjectFactory : IMxAccessComObjectFactory
{
/// <inheritdoc />
public object Create()
{
return new LMXProxyServerClass();
}
}
@@ -0,0 +1,234 @@
using System;
using ArchestrA.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Adapter exposing MXAccess COM object methods through the <see cref="IMxAccessServer"/>
/// interface.
/// </summary>
/// <remarks>
/// The supplied object must implement the typed MXAccess COM interface contract.
/// In production it is the <c>LMXProxyServerClass</c> RCW, which implements
/// <see cref="ILMXProxyServer"/> / <see cref="ILMXProxyServer3"/> /
/// <see cref="ILMXProxyServer4"/>. Tests substitute a typed fake that
/// implements <see cref="IMxAccessServer"/> directly. The earlier late-bound
/// <c>Type.InvokeMember</c> reflection fallback was removed: it bypassed the
/// typed interface contract, boxed value-type handles on every call, and only
/// ever served test doubles — a typed fake is the supported test seam now.
/// </remarks>
public sealed class MxAccessComServer : IMxAccessServer
{
private readonly object mxAccessComObject;
/// <summary>
/// Initializes the adapter with the MXAccess COM object.
/// </summary>
/// <param name="mxAccessComObject">
/// MXAccess COM object instance. Must implement either the typed
/// <see cref="ILMXProxyServer"/> COM interface family (production) or
/// <see cref="IMxAccessServer"/> directly (test fakes).
/// </param>
public MxAccessComServer(object mxAccessComObject)
{
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
}
/// <inheritdoc />
public int Register(string clientName)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
return typedFake.Register(clientName);
}
return AsProxyServer().Register(clientName);
}
/// <inheritdoc />
public void Unregister(int serverHandle)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.Unregister(serverHandle);
return;
}
AsProxyServer().Unregister(serverHandle);
}
/// <inheritdoc />
public int AddItem(
int serverHandle,
string itemDefinition)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
return typedFake.AddItem(serverHandle, itemDefinition);
}
return AsProxyServer().AddItem(serverHandle, itemDefinition);
}
/// <inheritdoc />
public int AddItem2(
int serverHandle,
string itemDefinition,
string itemContext)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
return typedFake.AddItem2(serverHandle, itemDefinition, itemContext);
}
return AsProxyServer3().AddItem2(serverHandle, itemDefinition, itemContext);
}
/// <inheritdoc />
public void RemoveItem(
int serverHandle,
int itemHandle)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.RemoveItem(serverHandle, itemHandle);
return;
}
AsProxyServer().RemoveItem(serverHandle, itemHandle);
}
/// <inheritdoc />
public void Advise(
int serverHandle,
int itemHandle)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.Advise(serverHandle, itemHandle);
return;
}
AsProxyServer().Advise(serverHandle, itemHandle);
}
/// <inheritdoc />
public void UnAdvise(
int serverHandle,
int itemHandle)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.UnAdvise(serverHandle, itemHandle);
return;
}
AsProxyServer().UnAdvise(serverHandle, itemHandle);
}
/// <inheritdoc />
public void AdviseSupervisory(
int serverHandle,
int itemHandle)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.AdviseSupervisory(serverHandle, itemHandle);
return;
}
AsProxyServer4().AdviseSupervisory(serverHandle, itemHandle);
}
/// <inheritdoc />
public void Write(
int serverHandle,
int itemHandle,
object? value,
int userId)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.Write(serverHandle, itemHandle, value, userId);
return;
}
AsProxyServer().Write(serverHandle, itemHandle, value!, userId);
}
/// <inheritdoc />
public void Write2(
int serverHandle,
int itemHandle,
object? value,
object? timestamp,
int userId)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.Write2(serverHandle, itemHandle, value, timestamp, userId);
return;
}
AsProxyServer4().Write2(serverHandle, itemHandle, value!, timestamp!, userId);
}
/// <inheritdoc />
public void WriteSecured(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value);
return;
}
AsProxyServer().WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value!);
}
/// <inheritdoc />
public void WriteSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value,
object? timestamp)
{
if (mxAccessComObject is IMxAccessServer typedFake)
{
typedFake.WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestamp);
return;
}
AsProxyServer4().WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value!, timestamp!);
}
private ILMXProxyServer AsProxyServer()
{
return mxAccessComObject as ILMXProxyServer
?? throw new InvalidOperationException(
$"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement "
+ $"{nameof(ILMXProxyServer)} or {nameof(IMxAccessServer)}.");
}
private ILMXProxyServer3 AsProxyServer3()
{
return mxAccessComObject as ILMXProxyServer3
?? throw new InvalidOperationException(
$"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement "
+ $"{nameof(ILMXProxyServer3)} or {nameof(IMxAccessServer)}.");
}
private ILMXProxyServer4 AsProxyServer4()
{
return mxAccessComObject as ILMXProxyServer4
?? throw new InvalidOperationException(
$"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement "
+ $"{nameof(ILMXProxyServer4)} or {nameof(IMxAccessServer)}.");
}
}
@@ -0,0 +1,873 @@
using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Executes MXAccess commands on an STA session.
/// </summary>
public sealed class MxAccessCommandExecutor : IStaCommandExecutor
{
/// <summary>Default per-tag timeout used when <c>ReadBulkCommand.timeout_ms</c> is zero.</summary>
internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000);
private readonly MxAccessSession session;
private readonly VariantConverter variantConverter;
private readonly IAlarmCommandHandler? alarmCommandHandler;
private readonly Action pumpStep;
/// <summary>
/// Initializes a command executor with an MXAccess session.
/// </summary>
/// <param name="session">MXAccess session on the STA thread.</param>
public MxAccessCommandExecutor(MxAccessSession session)
: this(session, new VariantConverter(), alarmCommandHandler: null, pumpStep: null)
{
}
/// <summary>
/// Initializes a command executor with an MXAccess session and a variant converter.
/// </summary>
/// <param name="session">MXAccess session on the STA thread.</param>
/// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param>
public MxAccessCommandExecutor(
MxAccessSession session,
VariantConverter variantConverter)
: this(session, variantConverter, alarmCommandHandler: null, pumpStep: null)
{
}
/// <summary>
/// Initializes a command executor with an MXAccess session, variant
/// converter, and an alarm command handler. The alarm handler is
/// optional — when null, alarm-side commands return an
/// "alarm consumer not configured" diagnostic.
/// </summary>
public MxAccessCommandExecutor(
MxAccessSession session,
VariantConverter variantConverter,
IAlarmCommandHandler? alarmCommandHandler)
: this(session, variantConverter, alarmCommandHandler, pumpStep: null)
{
}
/// <summary>
/// Initializes a command executor with an MXAccess session, variant
/// converter, alarm command handler, and a Windows-message pump action.
/// The pump action is invoked from inside <c>ReadBulk</c>'s wait loop so
/// MXAccess COM events queued for this STA can be dispatched while the
/// executor is still holding the thread. Pass <c>null</c> in tests where
/// ReadBulk is exercised against a fake worker that pre-populates the
/// value cache — the executor falls back to a no-op pump step.
/// </summary>
public MxAccessCommandExecutor(
MxAccessSession session,
VariantConverter variantConverter,
IAlarmCommandHandler? alarmCommandHandler,
Action? pumpStep)
{
this.session = session ?? throw new ArgumentNullException(nameof(session));
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.alarmCommandHandler = alarmCommandHandler;
this.pumpStep = pumpStep ?? (static () => { });
}
/// <summary>
/// Executes an MXAccess command and returns the reply.
/// </summary>
/// <param name="command">STA command to execute.</param>
/// <returns>Command reply with result or error details.</returns>
public MxCommandReply Execute(StaCommand command)
{
if (command is null)
{
throw new ArgumentNullException(nameof(command));
}
return command.Kind switch
{
MxCommandKind.Register => ExecuteRegister(command),
MxCommandKind.Unregister => ExecuteUnregister(command),
MxCommandKind.AddItem => ExecuteAddItem(command),
MxCommandKind.AddItem2 => ExecuteAddItem2(command),
MxCommandKind.RemoveItem => ExecuteRemoveItem(command),
MxCommandKind.Advise => ExecuteAdvise(command),
MxCommandKind.UnAdvise => ExecuteUnAdvise(command),
MxCommandKind.AdviseSupervisory => ExecuteAdviseSupervisory(command),
MxCommandKind.Write => ExecuteWrite(command),
MxCommandKind.Write2 => ExecuteWrite2(command),
MxCommandKind.WriteSecured => ExecuteWriteSecured(command),
MxCommandKind.WriteSecured2 => ExecuteWriteSecured2(command),
MxCommandKind.AddItemBulk => ExecuteAddItemBulk(command),
MxCommandKind.AdviseItemBulk => ExecuteAdviseItemBulk(command),
MxCommandKind.RemoveItemBulk => ExecuteRemoveItemBulk(command),
MxCommandKind.UnAdviseItemBulk => ExecuteUnAdviseItemBulk(command),
MxCommandKind.SubscribeBulk => ExecuteSubscribeBulk(command),
MxCommandKind.UnsubscribeBulk => ExecuteUnsubscribeBulk(command),
MxCommandKind.WriteBulk => ExecuteWriteBulk(command),
MxCommandKind.Write2Bulk => ExecuteWrite2Bulk(command),
MxCommandKind.WriteSecuredBulk => ExecuteWriteSecuredBulk(command),
MxCommandKind.WriteSecured2Bulk => ExecuteWriteSecured2Bulk(command),
MxCommandKind.ReadBulk => ExecuteReadBulk(command),
MxCommandKind.SubscribeAlarms => ExecuteSubscribeAlarms(command),
MxCommandKind.UnsubscribeAlarms => ExecuteUnsubscribeAlarms(command),
MxCommandKind.AcknowledgeAlarm => ExecuteAcknowledgeAlarm(command),
MxCommandKind.AcknowledgeAlarmByName => ExecuteAcknowledgeAlarmByName(command),
MxCommandKind.QueryActiveAlarms => ExecuteQueryActiveAlarms(command),
_ => CreateInvalidRequestReply(command, $"Unsupported MXAccess command kind {command.Kind}."),
};
}
private MxCommandReply ExecuteRegister(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Register)
{
return CreateInvalidRequestReply(command, "Register command payload is required.");
}
int serverHandle = session.Register(command.Command.Register.ClientName);
MxCommandReply reply = CreateOkReply(command);
reply.ReturnValue = variantConverter.Convert(serverHandle);
reply.Register = new RegisterReply
{
ServerHandle = serverHandle,
};
return reply;
}
private MxCommandReply ExecuteUnregister(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Unregister)
{
return CreateInvalidRequestReply(command, "Unregister command payload is required.");
}
session.Unregister(command.Command.Unregister.ServerHandle);
return CreateOkReply(command);
}
private MxCommandReply ExecuteAddItem(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItem)
{
return CreateInvalidRequestReply(command, "AddItem command payload is required.");
}
AddItemCommand addItemCommand = command.Command.AddItem;
int itemHandle = session.AddItem(
addItemCommand.ServerHandle,
addItemCommand.ItemDefinition);
MxCommandReply reply = CreateOkReply(command);
reply.ReturnValue = variantConverter.Convert(itemHandle);
reply.AddItem = new AddItemReply
{
ItemHandle = itemHandle,
};
return reply;
}
private MxCommandReply ExecuteAddItem2(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItem2)
{
return CreateInvalidRequestReply(command, "AddItem2 command payload is required.");
}
AddItem2Command addItem2Command = command.Command.AddItem2;
int itemHandle = session.AddItem2(
addItem2Command.ServerHandle,
addItem2Command.ItemDefinition,
addItem2Command.ItemContext);
MxCommandReply reply = CreateOkReply(command);
reply.ReturnValue = variantConverter.Convert(itemHandle);
reply.AddItem2 = new AddItem2Reply
{
ItemHandle = itemHandle,
};
return reply;
}
private MxCommandReply ExecuteRemoveItem(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.RemoveItem)
{
return CreateInvalidRequestReply(command, "RemoveItem command payload is required.");
}
RemoveItemCommand removeItemCommand = command.Command.RemoveItem;
session.RemoveItem(
removeItemCommand.ServerHandle,
removeItemCommand.ItemHandle);
return CreateOkReply(command);
}
private MxCommandReply ExecuteAdvise(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Advise)
{
return CreateInvalidRequestReply(command, "Advise command payload is required.");
}
AdviseCommand adviseCommand = command.Command.Advise;
session.Advise(
adviseCommand.ServerHandle,
adviseCommand.ItemHandle);
return CreateOkReply(command);
}
private MxCommandReply ExecuteUnAdvise(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnAdvise)
{
return CreateInvalidRequestReply(command, "UnAdvise command payload is required.");
}
UnAdviseCommand unAdviseCommand = command.Command.UnAdvise;
session.UnAdvise(
unAdviseCommand.ServerHandle,
unAdviseCommand.ItemHandle);
return CreateOkReply(command);
}
private MxCommandReply ExecuteAdviseSupervisory(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AdviseSupervisory)
{
return CreateInvalidRequestReply(command, "AdviseSupervisory command payload is required.");
}
AdviseSupervisoryCommand adviseSupervisoryCommand = command.Command.AdviseSupervisory;
session.AdviseSupervisory(
adviseSupervisoryCommand.ServerHandle,
adviseSupervisoryCommand.ItemHandle);
return CreateOkReply(command);
}
private MxCommandReply ExecuteWrite(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write)
{
return CreateInvalidRequestReply(command, "Write command payload is required.");
}
WriteCommand writeCommand = command.Command.Write;
if (writeCommand.Value is null)
{
return CreateInvalidRequestReply(command, "Write command value is required.");
}
session.Write(
writeCommand.ServerHandle,
writeCommand.ItemHandle,
variantConverter.ConvertToComValue(writeCommand.Value),
writeCommand.UserId);
return CreateOkReply(command);
}
private MxCommandReply ExecuteWrite2(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write2)
{
return CreateInvalidRequestReply(command, "Write2 command payload is required.");
}
Write2Command write2Command = command.Command.Write2;
if (write2Command.Value is null)
{
return CreateInvalidRequestReply(command, "Write2 command value is required.");
}
if (write2Command.TimestampValue is null)
{
return CreateInvalidRequestReply(command, "Write2 command timestamp value is required.");
}
session.Write2(
write2Command.ServerHandle,
write2Command.ItemHandle,
variantConverter.ConvertToComValue(write2Command.Value),
variantConverter.ConvertToComValue(write2Command.TimestampValue),
write2Command.UserId);
return CreateOkReply(command);
}
private MxCommandReply ExecuteWriteSecured(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured)
{
return CreateInvalidRequestReply(command, "WriteSecured command payload is required.");
}
WriteSecuredCommand writeSecuredCommand = command.Command.WriteSecured;
if (writeSecuredCommand.Value is null)
{
return CreateInvalidRequestReply(command, "WriteSecured command value is required.");
}
session.WriteSecured(
writeSecuredCommand.ServerHandle,
writeSecuredCommand.ItemHandle,
writeSecuredCommand.CurrentUserId,
writeSecuredCommand.VerifierUserId,
variantConverter.ConvertToComValue(writeSecuredCommand.Value));
return CreateOkReply(command);
}
private MxCommandReply ExecuteWriteSecured2(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured2)
{
return CreateInvalidRequestReply(command, "WriteSecured2 command payload is required.");
}
WriteSecured2Command writeSecured2Command = command.Command.WriteSecured2;
if (writeSecured2Command.Value is null)
{
return CreateInvalidRequestReply(command, "WriteSecured2 command value is required.");
}
if (writeSecured2Command.TimestampValue is null)
{
return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required.");
}
session.WriteSecured2(
writeSecured2Command.ServerHandle,
writeSecured2Command.ItemHandle,
writeSecured2Command.CurrentUserId,
writeSecured2Command.VerifierUserId,
variantConverter.ConvertToComValue(writeSecured2Command.Value),
variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue));
return CreateOkReply(command);
}
private MxCommandReply ExecuteAddItemBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItemBulk)
{
return CreateInvalidRequestReply(command, "AddItemBulk command payload is required.");
}
AddItemBulkCommand addItemBulkCommand = command.Command.AddItemBulk;
return CreateBulkReply(
command,
session.AddItemBulk(addItemBulkCommand.ServerHandle, addItemBulkCommand.TagAddresses));
}
private MxCommandReply ExecuteAdviseItemBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AdviseItemBulk)
{
return CreateInvalidRequestReply(command, "AdviseItemBulk command payload is required.");
}
AdviseItemBulkCommand adviseItemBulkCommand = command.Command.AdviseItemBulk;
return CreateBulkReply(
command,
session.AdviseItemBulk(adviseItemBulkCommand.ServerHandle, adviseItemBulkCommand.ItemHandles));
}
private MxCommandReply ExecuteRemoveItemBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.RemoveItemBulk)
{
return CreateInvalidRequestReply(command, "RemoveItemBulk command payload is required.");
}
RemoveItemBulkCommand removeItemBulkCommand = command.Command.RemoveItemBulk;
return CreateBulkReply(
command,
session.RemoveItemBulk(removeItemBulkCommand.ServerHandle, removeItemBulkCommand.ItemHandles));
}
private MxCommandReply ExecuteUnAdviseItemBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnAdviseItemBulk)
{
return CreateInvalidRequestReply(command, "UnAdviseItemBulk command payload is required.");
}
UnAdviseItemBulkCommand unAdviseItemBulkCommand = command.Command.UnAdviseItemBulk;
return CreateBulkReply(
command,
session.UnAdviseItemBulk(unAdviseItemBulkCommand.ServerHandle, unAdviseItemBulkCommand.ItemHandles));
}
private MxCommandReply ExecuteSubscribeBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.SubscribeBulk)
{
return CreateInvalidRequestReply(command, "SubscribeBulk command payload is required.");
}
SubscribeBulkCommand subscribeBulkCommand = command.Command.SubscribeBulk;
return CreateBulkReply(
command,
session.SubscribeBulk(subscribeBulkCommand.ServerHandle, subscribeBulkCommand.TagAddresses));
}
private MxCommandReply ExecuteUnsubscribeBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnsubscribeBulk)
{
return CreateInvalidRequestReply(command, "UnsubscribeBulk command payload is required.");
}
UnsubscribeBulkCommand unsubscribeBulkCommand = command.Command.UnsubscribeBulk;
return CreateBulkReply(
command,
session.UnsubscribeBulk(unsubscribeBulkCommand.ServerHandle, unsubscribeBulkCommand.ItemHandles));
}
private MxCommandReply ExecuteWriteBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteBulk)
{
return CreateInvalidRequestReply(command, "WriteBulk command payload is required.");
}
WriteBulkCommand writeBulkCommand = command.Command.WriteBulk;
foreach (WriteBulkEntry entry in writeBulkCommand.Entries)
{
if (entry.Value is null)
{
return CreateInvalidRequestReply(
command,
$"WriteBulk entry for item handle {entry.ItemHandle} is missing its value.");
}
}
return CreateBulkWriteReply(
command,
session.WriteBulk(
writeBulkCommand.ServerHandle,
writeBulkCommand.Entries,
value => variantConverter.ConvertToComValue(value)));
}
private MxCommandReply ExecuteWrite2Bulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write2Bulk)
{
return CreateInvalidRequestReply(command, "Write2Bulk command payload is required.");
}
Write2BulkCommand write2BulkCommand = command.Command.Write2Bulk;
foreach (Write2BulkEntry entry in write2BulkCommand.Entries)
{
if (entry.Value is null)
{
return CreateInvalidRequestReply(
command,
$"Write2Bulk entry for item handle {entry.ItemHandle} is missing its value.");
}
if (entry.TimestampValue is null)
{
return CreateInvalidRequestReply(
command,
$"Write2Bulk entry for item handle {entry.ItemHandle} is missing its timestamp value.");
}
}
return CreateBulkWriteReply(
command,
session.Write2Bulk(
write2BulkCommand.ServerHandle,
write2BulkCommand.Entries,
value => variantConverter.ConvertToComValue(value)));
}
private MxCommandReply ExecuteWriteSecuredBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecuredBulk)
{
return CreateInvalidRequestReply(command, "WriteSecuredBulk command payload is required.");
}
WriteSecuredBulkCommand writeSecuredBulkCommand = command.Command.WriteSecuredBulk;
foreach (WriteSecuredBulkEntry entry in writeSecuredBulkCommand.Entries)
{
if (entry.Value is null)
{
return CreateInvalidRequestReply(
command,
$"WriteSecuredBulk entry for item handle {entry.ItemHandle} is missing its value.");
}
}
return CreateBulkWriteReply(
command,
session.WriteSecuredBulk(
writeSecuredBulkCommand.ServerHandle,
writeSecuredBulkCommand.Entries,
value => variantConverter.ConvertToComValue(value)));
}
private MxCommandReply ExecuteWriteSecured2Bulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured2Bulk)
{
return CreateInvalidRequestReply(command, "WriteSecured2Bulk command payload is required.");
}
WriteSecured2BulkCommand writeSecured2BulkCommand = command.Command.WriteSecured2Bulk;
foreach (WriteSecured2BulkEntry entry in writeSecured2BulkCommand.Entries)
{
if (entry.Value is null)
{
return CreateInvalidRequestReply(
command,
$"WriteSecured2Bulk entry for item handle {entry.ItemHandle} is missing its value.");
}
if (entry.TimestampValue is null)
{
return CreateInvalidRequestReply(
command,
$"WriteSecured2Bulk entry for item handle {entry.ItemHandle} is missing its timestamp value.");
}
}
return CreateBulkWriteReply(
command,
session.WriteSecured2Bulk(
writeSecured2BulkCommand.ServerHandle,
writeSecured2BulkCommand.Entries,
value => variantConverter.ConvertToComValue(value)));
}
private MxCommandReply ExecuteReadBulk(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.ReadBulk)
{
return CreateInvalidRequestReply(command, "ReadBulk command payload is required.");
}
ReadBulkCommand readBulkCommand = command.Command.ReadBulk;
TimeSpan timeout = readBulkCommand.TimeoutMs == 0
? DefaultReadBulkTimeout
: TimeSpan.FromMilliseconds(readBulkCommand.TimeoutMs);
IReadOnlyList<BulkReadResult> results = session.ReadBulk(
readBulkCommand.ServerHandle,
readBulkCommand.TagAddresses,
timeout,
pumpStep);
MxCommandReply reply = CreateOkReply(command);
BulkReadReply bulkReply = new();
bulkReply.Results.Add(results);
reply.ReadBulk = bulkReply;
return reply;
}
private MxCommandReply ExecuteSubscribeAlarms(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.SubscribeAlarms)
{
return CreateInvalidRequestReply(command, "SubscribeAlarms command payload is required.");
}
if (alarmCommandHandler is null)
{
return CreateInvalidRequestReply(
command,
"SubscribeAlarms requires an alarm command handler; the worker was constructed without one.");
}
string subscription = command.Command.SubscribeAlarms.SubscriptionExpression ?? string.Empty;
if (string.IsNullOrWhiteSpace(subscription))
{
return CreateInvalidRequestReply(command, "SubscribeAlarms.subscription_expression is required.");
}
try
{
alarmCommandHandler.Subscribe(subscription, command.SessionId);
return CreateOkReply(command);
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private MxCommandReply ExecuteUnsubscribeAlarms(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnsubscribeAlarms)
{
return CreateInvalidRequestReply(command, "UnsubscribeAlarms command payload is required.");
}
if (alarmCommandHandler is null)
{
// No handler configured — Unsubscribe is a no-op in that case;
// it can't be in a subscribed state to begin with.
return CreateOkReply(command);
}
try
{
alarmCommandHandler.Unsubscribe();
return CreateOkReply(command);
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private MxCommandReply ExecuteAcknowledgeAlarm(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AcknowledgeAlarmCommand)
{
return CreateInvalidRequestReply(command, "AcknowledgeAlarm command payload is required.");
}
if (alarmCommandHandler is null)
{
return CreateInvalidRequestReply(
command,
"AcknowledgeAlarm requires an alarm command handler; the worker was constructed without one.");
}
AcknowledgeAlarmCommand payload = command.Command.AcknowledgeAlarmCommand;
if (!Guid.TryParse(payload.AlarmGuid, out Guid alarmGuid))
{
return CreateInvalidRequestReply(
command,
$"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'.");
}
try
{
int rc = alarmCommandHandler.Acknowledge(
alarmGuid,
payload.Comment,
payload.OperatorUser,
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}.";
}
return reply;
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private MxCommandReply ExecuteAcknowledgeAlarmByName(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AcknowledgeAlarmByNameCommand)
{
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName command payload is required.");
}
if (alarmCommandHandler is null)
{
return CreateInvalidRequestReply(
command,
"AcknowledgeAlarmByName requires an alarm command handler; the worker was constructed without one.");
}
AcknowledgeAlarmByNameCommand payload = command.Command.AcknowledgeAlarmByNameCommand;
if (string.IsNullOrWhiteSpace(payload.AlarmName))
{
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required.");
}
try
{
int rc = alarmCommandHandler.AcknowledgeByName(
payload.AlarmName,
payload.ProviderName,
payload.GroupName,
payload.Comment,
payload.OperatorUser,
payload.OperatorNode,
payload.OperatorDomain,
payload.OperatorFullName);
MxCommandReply reply = CreateOkReply(command);
reply.Hresult = rc;
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
{
NativeStatus = rc,
};
if (rc != 0)
{
reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}.";
}
return reply;
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)
{
return CreateInvalidRequestReply(command, "QueryActiveAlarms command payload is required.");
}
if (alarmCommandHandler is null)
{
return CreateInvalidRequestReply(
command,
"QueryActiveAlarms requires an alarm command handler; the worker was constructed without one.");
}
try
{
IReadOnlyList<ActiveAlarmSnapshot> snapshots = alarmCommandHandler.QueryActive(
command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix);
QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload();
payload.Snapshots.AddRange(snapshots);
MxCommandReply reply = CreateOkReply(command);
reply.QueryActiveAlarms = payload;
return reply;
}
catch (Exception ex)
{
return CreateAlarmFailureReply(command, ex);
}
}
private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception)
{
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.MxaccessFailure,
Message = exception.Message,
},
DiagnosticMessage = $"{exception.GetType().FullName}: {exception.Message}",
};
}
private static MxCommandReply CreateOkReply(StaCommand command)
{
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
Hresult = 0,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.Ok,
Message = "OK",
},
};
}
private static MxCommandReply CreateBulkReply(
StaCommand command,
IEnumerable<SubscribeResult> results)
{
MxCommandReply reply = CreateOkReply(command);
BulkSubscribeReply bulkReply = new();
bulkReply.Results.Add(results);
switch (command.Kind)
{
case MxCommandKind.AddItemBulk:
reply.AddItemBulk = bulkReply;
break;
case MxCommandKind.AdviseItemBulk:
reply.AdviseItemBulk = bulkReply;
break;
case MxCommandKind.RemoveItemBulk:
reply.RemoveItemBulk = bulkReply;
break;
case MxCommandKind.UnAdviseItemBulk:
reply.UnAdviseItemBulk = bulkReply;
break;
case MxCommandKind.SubscribeBulk:
reply.SubscribeBulk = bulkReply;
break;
case MxCommandKind.UnsubscribeBulk:
reply.UnsubscribeBulk = bulkReply;
break;
default:
throw new InvalidOperationException($"Unsupported bulk command kind {command.Kind}.");
}
return reply;
}
private static MxCommandReply CreateBulkWriteReply(
StaCommand command,
IEnumerable<BulkWriteResult> results)
{
MxCommandReply reply = CreateOkReply(command);
BulkWriteReply bulkReply = new();
bulkReply.Results.Add(results);
switch (command.Kind)
{
case MxCommandKind.WriteBulk:
reply.WriteBulk = bulkReply;
break;
case MxCommandKind.Write2Bulk:
reply.Write2Bulk = bulkReply;
break;
case MxCommandKind.WriteSecuredBulk:
reply.WriteSecuredBulk = bulkReply;
break;
case MxCommandKind.WriteSecured2Bulk:
reply.WriteSecured2Bulk = bulkReply;
break;
default:
throw new InvalidOperationException($"Unsupported bulk write command kind {command.Kind}.");
}
return reply;
}
private static MxCommandReply CreateInvalidRequestReply(
StaCommand command,
string message)
{
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.InvalidRequest,
Message = message,
},
DiagnosticMessage = message,
};
}
}
@@ -0,0 +1,61 @@
using System;
using System.Runtime.InteropServices;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Thrown when the worker fails to instantiate the MXAccess COM object.</summary>
public sealed class MxAccessCreationException : Exception
{
/// <summary>Initializes a new instance with diagnostic info from the inner exception.</summary>
/// <param name="innerException">The exception that caused the creation failure.</param>
public MxAccessCreationException(Exception innerException)
: base(
$"Failed to create MXAccess COM object {MxAccessInteropInfo.ComClassName} ({MxAccessInteropInfo.ProgId}).",
innerException)
{
AttemptedProgId = MxAccessInteropInfo.ProgId;
AttemptedClsid = MxAccessInteropInfo.Clsid;
AttemptedComClassName = MxAccessInteropInfo.ComClassName;
HResult = innerException.HResult;
}
/// <summary>The ProgID that was attempted during COM instantiation.</summary>
public string AttemptedProgId { get; }
/// <summary>The CLSID that was attempted during COM instantiation.</summary>
public string AttemptedClsid { get; }
/// <summary>The COM class name that was attempted during instantiation.</summary>
public string AttemptedComClassName { get; }
/// <summary>The captured HResult from the instantiation failure, or null if zero.</summary>
public int? CapturedHResult => HResult == 0 ? null : HResult;
/// <summary>Wraps an exception in MxAccessCreationException if it is not already.</summary>
/// <param name="exception">The exception to wrap.</param>
/// <returns>An MxAccessCreationException wrapping the input exception.</returns>
public static MxAccessCreationException From(Exception exception)
{
return exception is MxAccessCreationException creationException
? creationException
: new MxAccessCreationException(exception);
}
/// <summary>Extracts the HResult from an exception, handling MXAccess and COM exceptions specially.</summary>
/// <param name="exception">The exception to extract the HResult from.</param>
/// <returns>The HResult value, or null if zero.</returns>
public static int? ExtractHResult(Exception exception)
{
if (exception is MxAccessCreationException creationException)
{
return creationException.CapturedHResult;
}
if (exception is COMException comException)
{
return comException.HResult;
}
return exception.HResult == 0 ? null : exception.HResult;
}
}
@@ -0,0 +1,368 @@
using System;
using System.Globalization;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Maps MXAccess COM events to protobuf MxEvent messages.</summary>
public sealed class MxAccessEventMapper
{
private readonly VariantConverter variantConverter;
private readonly MxStatusProxyConverter statusProxyConverter;
/// <summary>Initializes a new instance of the MxAccessEventMapper class with default converters.</summary>
public MxAccessEventMapper()
: this(new VariantConverter(), new MxStatusProxyConverter())
{
}
/// <summary>Initializes a new instance of the MxAccessEventMapper class with provided converters.</summary>
/// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param>
/// <param name="statusProxyConverter">Converter for MXAccess status arrays to MxStatusProxy protobuf messages.</param>
public MxAccessEventMapper(
VariantConverter variantConverter,
MxStatusProxyConverter statusProxyConverter)
{
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.statusProxyConverter = statusProxyConverter ?? throw new ArgumentNullException(nameof(statusProxyConverter));
}
/// <summary>Creates an OnDataChange event from MXAccess COM event arguments.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="value">Item value received from MXAccess.</param>
/// <param name="quality">Item quality code from MXAccess.</param>
/// <param name="timestamp">Item timestamp from MXAccess.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
public MxEvent CreateOnDataChange(
string sessionId,
int serverHandle,
int itemHandle,
object? value,
int quality,
object? timestamp,
Array? statuses)
{
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OnDataChange,
sessionId,
serverHandle,
itemHandle,
statuses);
mxEvent.Value = variantConverter.Convert(value);
mxEvent.Quality = quality;
ApplySourceTimestamp(mxEvent, timestamp);
mxEvent.OnDataChange = new OnDataChangeEvent();
return mxEvent;
}
/// <summary>Creates an OnWriteComplete event from MXAccess COM event arguments.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
public MxEvent CreateOnWriteComplete(
string sessionId,
int serverHandle,
int itemHandle,
Array? statuses)
{
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OnWriteComplete,
sessionId,
serverHandle,
itemHandle,
statuses);
mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
return mxEvent;
}
/// <summary>Creates an OperationComplete event from MXAccess COM event arguments.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
public MxEvent CreateOperationComplete(
string sessionId,
int serverHandle,
int itemHandle,
Array? statuses)
{
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OperationComplete,
sessionId,
serverHandle,
itemHandle,
statuses);
mxEvent.OperationComplete = new OperationCompleteEvent();
return mxEvent;
}
/// <summary>
/// Creates an OnAlarmTransition event from MXAccess alarm-event arguments.
/// The worker's alarm path drives this method from
/// <see cref="MxAccessAlarmEventSink.EnqueueTransition"/> once
/// <see cref="AlarmDispatcher"/> decodes a transition raised by the
/// wnwrap-backed <see cref="WnWrapAlarmConsumer"/>.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="alarmFullReference">Fully-qualified MxAccess alarm reference (e.g. "Tank01.Level.HiHi").</param>
/// <param name="sourceObjectReference">Galaxy-side source object reference; empty when not bound to a Galaxy object.</param>
/// <param name="alarmTypeName">MxAccess alarm-type qualifier (e.g. "AnalogLimitAlarm.HiHi").</param>
/// <param name="transitionKind">Discriminator: Raise / Acknowledge / Clear / Retrigger.</param>
/// <param name="severity">Raw MxAccess severity (kept on the native scale; lmxopcua maps to OPC UA 0-1000).</param>
/// <param name="originalRaiseTimestampUtc">When the alarm originally entered active; null on retrigger.</param>
/// <param name="transitionTimestampUtc">When this specific transition occurred.</param>
/// <param name="operatorUser">Operator principal recorded by MxAccess on Acknowledge transitions; empty on raise/clear.</param>
/// <param name="operatorComment">Operator-supplied comment recorded by MxAccess on Acknowledge transitions; empty on raise/clear.</param>
/// <param name="category">Alarm taxonomy bucket from the Galaxy template.</param>
/// <param name="description">Human-readable alarm description.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
public MxEvent CreateOnAlarmTransition(
string sessionId,
string alarmFullReference,
string sourceObjectReference,
string alarmTypeName,
AlarmTransitionKind transitionKind,
int severity,
DateTime? originalRaiseTimestampUtc,
DateTime transitionTimestampUtc,
string operatorUser,
string operatorComment,
string category,
string description,
Array? statuses)
{
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OnAlarmTransition,
sessionId,
serverHandle: 0,
itemHandle: 0,
statuses);
OnAlarmTransitionEvent body = new()
{
AlarmFullReference = alarmFullReference ?? string.Empty,
SourceObjectReference = sourceObjectReference ?? string.Empty,
AlarmTypeName = alarmTypeName ?? string.Empty,
TransitionKind = transitionKind,
Severity = severity,
TransitionTimestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
DateTime.SpecifyKind(transitionTimestampUtc, DateTimeKind.Utc)),
OperatorUser = operatorUser ?? string.Empty,
OperatorComment = operatorComment ?? string.Empty,
Category = category ?? string.Empty,
Description = description ?? string.Empty,
};
if (originalRaiseTimestampUtc is { } orts)
{
body.OriginalRaiseTimestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
DateTime.SpecifyKind(orts, DateTimeKind.Utc));
}
mxEvent.OnAlarmTransition = body;
return mxEvent;
}
/// <summary>Creates an OnBufferedDataChange event from MXAccess COM event arguments.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="rawDataType">Raw MXAccess data type code for the buffered value.</param>
/// <param name="value">Item value received from MXAccess.</param>
/// <param name="quality">Array of quality values from MXAccess.</param>
/// <param name="timestamp">Array of timestamp values from MXAccess.</param>
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
public MxEvent CreateOnBufferedDataChange(
string sessionId,
int serverHandle,
int itemHandle,
int rawDataType,
object? value,
object? quality,
object? timestamp,
Array? statuses)
{
MxDataType dataType = MapMxDataType(rawDataType);
MxEvent mxEvent = CreateBaseEvent(
MxEventFamily.OnBufferedDataChange,
sessionId,
serverHandle,
itemHandle,
statuses);
mxEvent.Value = variantConverter.Convert(value, dataType);
mxEvent.OnBufferedDataChange = new OnBufferedDataChangeEvent
{
DataType = dataType,
RawDataType = rawDataType,
QualityValues = ConvertBufferedArray(quality, MxDataType.Integer),
TimestampValues = ConvertBufferedArray(timestamp, MxDataType.Time),
};
return mxEvent;
}
/// <summary>Maps a raw MXAccess data type code to the MxDataType enum.</summary>
/// <param name="rawDataType">Raw MXAccess data type value to map.</param>
/// <returns>The corresponding MxDataType enum value.</returns>
public static MxDataType MapMxDataType(int rawDataType)
{
return rawDataType switch
{
-1 => MxDataType.Unknown,
0 => MxDataType.NoData,
1 => MxDataType.Boolean,
2 => MxDataType.Integer,
3 => MxDataType.Float,
4 => MxDataType.Double,
5 => MxDataType.String,
6 => MxDataType.Time,
7 => MxDataType.ElapsedTime,
8 => MxDataType.ReferenceType,
9 => MxDataType.StatusType,
10 => MxDataType.Enum,
11 => MxDataType.SecurityClassificationEnum,
12 => MxDataType.DataQualityType,
13 => MxDataType.QualifiedEnum,
14 => MxDataType.QualifiedStruct,
15 => MxDataType.InternationalizedString,
16 => MxDataType.BigString,
17 => MxDataType.End,
_ => MxDataType.Unknown,
};
}
private MxEvent CreateBaseEvent(
MxEventFamily family,
string sessionId,
int serverHandle,
int itemHandle,
Array? statuses)
{
MxEvent mxEvent = new()
{
Family = family,
SessionId = sessionId ?? string.Empty,
ServerHandle = serverHandle,
ItemHandle = itemHandle,
};
mxEvent.Statuses.Add(statusProxyConverter.ConvertMany(statuses));
return mxEvent;
}
private void ApplySourceTimestamp(
MxEvent mxEvent,
object? timestamp)
{
MxValue convertedTimestamp = variantConverter.Convert(timestamp, MxDataType.Time);
if (convertedTimestamp.KindCase == MxValue.KindOneofCase.TimestampValue)
{
mxEvent.SourceTimestamp = convertedTimestamp.TimestampValue;
return;
}
// MXAccess fires OnDataChange with pftItemTimeStamp marshaled as a
// VT_BSTR string (e.g. "3/26/2026 1:38:22.907 PM"), not a FILETIME or
// a VT_DATE — so the variant converter classifies it as a plain
// string and the timestamp would otherwise be dropped. Parse it here
// so the source timestamp still reaches MxEvent. MXAccess formats the
// string in the worker host's local time; see TryParseSourceTimestamp.
if (convertedTimestamp.KindCase == MxValue.KindOneofCase.StringValue
&& TryParseSourceTimestamp(convertedTimestamp.StringValue, out DateTime parsedUtc))
{
mxEvent.SourceTimestamp = Timestamp.FromDateTime(parsedUtc);
return;
}
if (!string.IsNullOrWhiteSpace(convertedTimestamp.RawDiagnostic))
{
mxEvent.RawStatus = string.IsNullOrWhiteSpace(mxEvent.RawStatus)
? convertedTimestamp.RawDiagnostic
: $"{mxEvent.RawStatus}; {convertedTimestamp.RawDiagnostic}";
}
}
/// <summary>
/// Parses an MXAccess <c>OnDataChange</c> timestamp string into a UTC
/// <see cref="DateTime"/>. MXAccess delivers the value as a culture-
/// formatted string rather than a FILETIME or VT_DATE, and formats it
/// in the worker host's <em>local</em> time (verified empirically — a
/// fast-changing tag's timestamp lands the host's UTC offset behind
/// wall-clock UTC). The parsed value is therefore taken as local time
/// and converted to UTC. Tries the worker host's culture first
/// (MXAccess formats with the host locale), then the invariant culture.
/// </summary>
/// <param name="text">The MXAccess timestamp string.</param>
/// <param name="utc">The parsed UTC timestamp on success.</param>
/// <returns><see langword="true"/> when the string parsed successfully.</returns>
internal static bool TryParseSourceTimestamp(string? text, out DateTime utc)
{
utc = default;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed)
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
{
utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
return true;
}
return false;
}
private MxArray ConvertBufferedArray(
object? value,
MxDataType expectedElementDataType)
{
if (value is Array array)
{
return variantConverter.ConvertArray(array, expectedElementDataType);
}
MxValue converted = variantConverter.Convert(value, expectedElementDataType);
if (converted.KindCase == MxValue.KindOneofCase.ArrayValue)
{
return converted.ArrayValue;
}
MxArray mxArray = new()
{
ElementDataType = converted.DataType,
VariantType = converted.VariantType,
RawElementDataType = converted.RawDataType,
RawDiagnostic = string.IsNullOrWhiteSpace(converted.RawDiagnostic)
? "Buffered MXAccess event argument was not a SAFEARRAY."
: converted.RawDiagnostic,
};
switch (converted.KindCase)
{
case MxValue.KindOneofCase.Int32Value:
mxArray.Int32Values = new Int32Array();
mxArray.Int32Values.Values.Add(converted.Int32Value);
break;
case MxValue.KindOneofCase.Int64Value:
mxArray.Int64Values = new Int64Array();
mxArray.Int64Values.Values.Add(converted.Int64Value);
break;
case MxValue.KindOneofCase.TimestampValue:
mxArray.TimestampValues = new TimestampArray();
mxArray.TimestampValues.Values.Add(converted.TimestampValue);
break;
}
return mxArray;
}
}
@@ -0,0 +1,240 @@
using System;
using System.Collections.Generic;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Thread-safe queue for MxAccess events with capacity overflow and fault tracking.
/// </summary>
public sealed class MxAccessEventQueue
{
/// <summary>
/// Default queue capacity (10,000 events).
/// </summary>
public const int DefaultCapacity = 10000;
private readonly int capacity;
private readonly Queue<WorkerEvent> events;
private readonly object syncRoot = new();
private ulong lastEventSequence;
private WorkerFault? fault;
private bool faultDrained;
/// <summary>
/// Initializes the queue with the default capacity.
/// </summary>
public MxAccessEventQueue()
: this(DefaultCapacity)
{
}
/// <summary>
/// Initializes the queue with the specified capacity.
/// </summary>
/// <param name="capacity">Maximum number of events the queue can hold.</param>
public MxAccessEventQueue(int capacity)
{
if (capacity <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(capacity),
"MXAccess event queue capacity must be greater than zero.");
}
this.capacity = capacity;
events = new Queue<WorkerEvent>(capacity);
}
/// <summary>
/// The queue's maximum capacity.
/// </summary>
public int Capacity => capacity;
/// <summary>
/// The current number of events in the queue.
/// </summary>
public int Count
{
get
{
lock (syncRoot)
{
return events.Count;
}
}
}
/// <summary>
/// The highest event sequence number assigned.
/// </summary>
public ulong LastEventSequence
{
get
{
lock (syncRoot)
{
return lastEventSequence;
}
}
}
/// <summary>
/// Indicates whether the queue is in a faulted state.
/// </summary>
public bool IsFaulted
{
get
{
lock (syncRoot)
{
return fault is not null;
}
}
}
/// <summary>
/// The current fault if the queue is faulted, or null.
/// </summary>
public WorkerFault? Fault
{
get
{
lock (syncRoot)
{
return fault?.Clone();
}
}
}
/// <summary>
/// Enqueues an MxAccess event, assigning a sequence number and timestamp.
/// </summary>
/// <param name="mxEvent">MXAccess event to enqueue.</param>
public void Enqueue(MxEvent mxEvent)
{
if (mxEvent is null)
{
throw new ArgumentNullException(nameof(mxEvent));
}
lock (syncRoot)
{
if (fault is not null)
{
throw new InvalidOperationException("MXAccess outbound event queue is faulted.");
}
if (events.Count >= capacity)
{
fault = CreateOverflowFault();
throw new MxAccessEventQueueOverflowException(capacity);
}
MxEvent queuedEvent = mxEvent.Clone();
queuedEvent.WorkerSequence = ++lastEventSequence;
queuedEvent.WorkerTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
WorkerEvent workerEvent = new()
{
Event = queuedEvent,
};
events.Enqueue(workerEvent);
}
}
/// <summary>
/// Attempts to dequeue the next event without removing it if empty.
/// </summary>
/// <param name="workerEvent">The dequeued event if successful; null if queue is empty.</param>
public bool TryDequeue(out WorkerEvent? workerEvent)
{
lock (syncRoot)
{
if (events.Count == 0)
{
workerEvent = null;
return false;
}
workerEvent = events.Dequeue();
return true;
}
}
/// <summary>
/// Drains up to maxEvents from the queue; if maxEvents is 0, drains all events.
/// </summary>
/// <param name="maxEvents">Maximum number of events to drain; 0 means drain all.</param>
public IReadOnlyList<WorkerEvent> Drain(uint maxEvents)
{
lock (syncRoot)
{
int drainCount = maxEvents == 0
? events.Count
: Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue)));
if (drainCount == 0)
{
return Array.Empty<WorkerEvent>();
}
List<WorkerEvent> drained = new(drainCount);
for (int index = 0; index < drainCount; index++)
{
drained.Add(events.Dequeue());
}
return drained;
}
}
/// <summary>
/// Records a fault if one has not already been recorded.
/// </summary>
/// <param name="workerFault">Worker fault to record.</param>
public void RecordFault(WorkerFault workerFault)
{
if (workerFault is null)
{
throw new ArgumentNullException(nameof(workerFault));
}
lock (syncRoot)
{
fault ??= workerFault.Clone();
}
}
/// <summary>
/// Returns and clears the fault so it is not reported twice.
/// </summary>
public WorkerFault? DrainFault()
{
lock (syncRoot)
{
if (fault is null || faultDrained)
{
return null;
}
faultDrained = true;
return fault.Clone();
}
}
private WorkerFault CreateOverflowFault()
{
string message = $"MXAccess outbound event queue reached capacity {capacity}.";
return new WorkerFault
{
Category = WorkerFaultCategory.QueueOverflow,
DiagnosticMessage = message,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.WorkerUnavailable,
Message = message,
},
};
}
}
@@ -0,0 +1,21 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class MxAccessEventQueueOverflowException : Exception
{
/// <summary>
/// Initializes a new instance of <see cref="MxAccessEventQueueOverflowException"/>.
/// </summary>
/// <param name="capacity">Queue capacity.</param>
public MxAccessEventQueueOverflowException(int capacity)
: base($"MXAccess outbound event queue reached its configured capacity of {capacity}.")
{
Capacity = capacity;
}
/// <summary>
/// Gets the queue capacity.
/// </summary>
public int Capacity { get; }
}
@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class MxAccessHandleRegistry
{
private readonly Dictionary<int, RegisteredServerHandle> serverHandles = new();
private readonly Dictionary<long, RegisteredItemHandle> itemHandles = new();
private readonly Dictionary<AdviceHandleKey, RegisteredAdviceHandle> adviceHandles = new();
/// <summary>Gets a read-only list of registered server handles ordered by handle value.</summary>
public IReadOnlyList<RegisteredServerHandle> ServerHandles => serverHandles
.Values
.OrderBy(handle => handle.ServerHandle)
.ToArray();
/// <summary>Gets a read-only list of registered item handles ordered by server handle then item handle.</summary>
public IReadOnlyList<RegisteredItemHandle> ItemHandles => itemHandles
.Values
.OrderBy(handle => handle.ServerHandle)
.ThenBy(handle => handle.ItemHandle)
.ToArray();
/// <summary>Gets a read-only list of registered advice handles ordered by server handle, item handle, and advice kind.</summary>
public IReadOnlyList<RegisteredAdviceHandle> AdviceHandles => adviceHandles
.Values
.OrderBy(handle => handle.ServerHandle)
.ThenBy(handle => handle.ItemHandle)
.ThenBy(handle => handle.AdviceKind)
.ToArray();
/// <summary>Registers a server handle with the registry.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="clientName">Display name of the client that owns the server handle.</param>
public void RegisterServerHandle(
int serverHandle,
string clientName)
{
serverHandles[serverHandle] = new RegisteredServerHandle(serverHandle, clientName);
}
/// <summary>Unregisters a server handle and all associated item and advice handles from the registry.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
public void UnregisterServerHandle(int serverHandle)
{
serverHandles.Remove(serverHandle);
foreach (long key in itemHandles
.Where(pair => pair.Value.ServerHandle == serverHandle)
.Select(pair => pair.Key)
.ToArray())
{
itemHandles.Remove(key);
}
foreach (AdviceHandleKey key in adviceHandles
.Where(pair => pair.Value.ServerHandle == serverHandle)
.Select(pair => pair.Key)
.ToArray())
{
adviceHandles.Remove(key);
}
}
/// <summary>Checks if the registry contains the specified server handle.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
public bool ContainsServerHandle(int serverHandle)
{
return serverHandles.ContainsKey(serverHandle);
}
/// <summary>Registers an item handle with the registry.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="itemDefinition">Item definition name from MXAccess.</param>
/// <param name="itemContext">Item context from MXAccess, or empty string if none.</param>
/// <param name="hasItemContext">True if the item has a context; false otherwise.</param>
public void RegisterItemHandle(
int serverHandle,
int itemHandle,
string itemDefinition,
string itemContext,
bool hasItemContext)
{
itemHandles[CreateItemKey(serverHandle, itemHandle)] = new RegisteredItemHandle(
serverHandle,
itemHandle,
itemDefinition,
itemContext,
hasItemContext);
}
/// <summary>Removes an item handle and all associated advice handles from the registry.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void RemoveItemHandle(
int serverHandle,
int itemHandle)
{
itemHandles.Remove(CreateItemKey(serverHandle, itemHandle));
RemoveAdviceHandles(serverHandle, itemHandle);
}
/// <summary>Checks if the registry contains the specified item handle.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public bool ContainsItemHandle(
int serverHandle,
int itemHandle)
{
return itemHandles.ContainsKey(CreateItemKey(serverHandle, itemHandle));
}
/// <summary>Registers an advice handle with the registry.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="adviceKind">Type of advice to register.</param>
public void RegisterAdviceHandle(
int serverHandle,
int itemHandle,
MxAccessAdviceKind adviceKind)
{
AdviceHandleKey key = new(serverHandle, itemHandle, adviceKind);
adviceHandles[key] = new RegisteredAdviceHandle(
serverHandle,
itemHandle,
adviceKind);
}
/// <summary>Removes all advice handles for the specified server and item handles from the registry.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void RemoveAdviceHandles(
int serverHandle,
int itemHandle)
{
foreach (AdviceHandleKey key in adviceHandles
.Where(pair => pair.Value.ServerHandle == serverHandle && pair.Value.ItemHandle == itemHandle)
.Select(pair => pair.Key)
.ToArray())
{
adviceHandles.Remove(key);
}
}
/// <summary>Checks if the registry contains the specified advice handle.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="adviceKind">Type of advice to check.</param>
public bool ContainsAdviceHandle(
int serverHandle,
int itemHandle,
MxAccessAdviceKind adviceKind)
{
return adviceHandles.ContainsKey(new AdviceHandleKey(serverHandle, itemHandle, adviceKind));
}
private static long CreateItemKey(
int serverHandle,
int itemHandle)
{
return ((long)serverHandle << 32) | (uint)itemHandle;
}
private readonly struct AdviceHandleKey : IEquatable<AdviceHandleKey>
{
private readonly int serverHandle;
private readonly int itemHandle;
private readonly MxAccessAdviceKind adviceKind;
/// <summary>Initializes a new instance of the <see cref="AdviceHandleKey"/> struct.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="adviceKind">Type of advice.</param>
public AdviceHandleKey(
int serverHandle,
int itemHandle,
MxAccessAdviceKind adviceKind)
{
this.serverHandle = serverHandle;
this.itemHandle = itemHandle;
this.adviceKind = adviceKind;
}
/// <inheritdoc />
public bool Equals(AdviceHandleKey other)
{
return serverHandle == other.serverHandle
&& itemHandle == other.itemHandle
&& adviceKind == other.adviceKind;
}
/// <inheritdoc />
public override bool Equals(object? obj)
{
return obj is AdviceHandleKey other && Equals(other);
}
/// <inheritdoc />
public override int GetHashCode()
{
unchecked
{
int hashCode = serverHandle;
hashCode = (hashCode * 397) ^ itemHandle;
hashCode = (hashCode * 397) ^ (int)adviceKind;
return hashCode;
}
}
}
}
@@ -0,0 +1,54 @@
using System;
using ArchestrA.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Constants and metadata for MXAccess COM interop.
/// </summary>
public static class MxAccessInteropInfo
{
/// <summary>
/// Versioned ProgID for the MXAccess COM server.
/// </summary>
public const string ProgId = "LMXProxy.LMXProxyServer.1";
/// <summary>
/// Version-independent ProgID for the MXAccess COM server.
/// </summary>
public const string VersionIndependentProgId = "LMXProxy.LMXProxyServer";
/// <summary>
/// Class ID (CLSID) of the MXAccess COM server.
/// </summary>
public const string Clsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}";
/// <summary>
/// Path to the ArchestrA.MxAccess.dll interop assembly.
/// </summary>
public const string InteropAssemblyPath =
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll";
/// <summary>
/// Path to the installed MXAccess COM server DLL.
/// </summary>
public const string RegisteredServerPath =
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll";
/// <summary>
/// Full qualified name of the COM class.
/// </summary>
public const string ComClassName = "ArchestrA.MxAccess.LMXProxyServerClass";
/// <summary>
/// Name of the MXAccess interop assembly.
/// </summary>
public static string InteropAssemblyName =>
typeof(LMXProxyServerClass).Assembly.GetName().Name ?? string.Empty;
/// <summary>
/// Version of the MXAccess interop assembly.
/// </summary>
public static Version InteropAssemblyVersion =>
typeof(LMXProxyServerClass).Assembly.GetName().Version ?? new Version(0, 0);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Captures details about an MXAccess operation that failed during shutdown.
/// </summary>
public sealed class MxAccessShutdownFailure
{
/// <summary>
/// Initializes the shutdown failure record.
/// </summary>
/// <param name="operation">Name of the operation that failed.</param>
/// <param name="serverHandle">Server handle if applicable.</param>
/// <param name="itemHandle">Item handle if applicable.</param>
/// <param name="exception">Exception that was raised.</param>
public MxAccessShutdownFailure(
string operation,
int? serverHandle,
int? itemHandle,
Exception exception)
{
if (string.IsNullOrWhiteSpace(operation))
{
throw new ArgumentException("Shutdown failure operation is required.", nameof(operation));
}
Operation = operation;
ServerHandle = serverHandle;
ItemHandle = itemHandle;
ExceptionType = exception?.GetType().FullName ?? string.Empty;
HResult = exception?.HResult;
}
/// <summary>
/// The operation that failed (e.g., Unregister, RemoveItem).
/// </summary>
public string Operation { get; }
/// <summary>
/// Server handle if applicable; otherwise null.
/// </summary>
public int? ServerHandle { get; }
/// <summary>
/// Item handle if applicable; otherwise null.
/// </summary>
public int? ItemHandle { get; }
/// <summary>
/// Full type name of the exception, or empty string if no exception.
/// </summary>
public string ExceptionType { get; }
/// <summary>
/// HResult code if the exception has one; otherwise null.
/// </summary>
public int? HResult { get; }
}
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class MxAccessShutdownResult
{
/// <summary>Initializes a new instance of the <see cref="MxAccessShutdownResult"/> class.</summary>
/// <param name="failures">List of failures encountered during graceful shutdown.</param>
public MxAccessShutdownResult(IReadOnlyList<MxAccessShutdownFailure> failures)
{
Failures = failures ?? throw new ArgumentNullException(nameof(failures));
}
/// <summary>Gets the list of shutdown failures.</summary>
public IReadOnlyList<MxAccessShutdownFailure> Failures { get; }
/// <summary>Gets a value indicating whether the shutdown succeeded with no failures.</summary>
public bool Succeeded => Failures.Count == 0;
}
@@ -0,0 +1,672 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Conversion;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class MxAccessStaSession : IWorkerRuntimeSession
{
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
private readonly IMxAccessComObjectFactory factory;
private readonly IMxAccessEventSink eventSink;
private readonly MxAccessEventQueue eventQueue;
private readonly StaRuntime staRuntime;
// Worker-024: the factory takes an Action so MxAccessStaSession can hand
// the alarm handler its STA-affinity guard (a closure over
// alarmConsumerThreadId captured at the factory call site). The handler
// then invokes the guard at the entry of every method that touches the
// wnwrap consumer, matching the STA-affinity invariant already enforced
// for the poll path via EnsureOnAlarmConsumerThread.
private readonly Func<MxAccessEventQueue, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory;
private StaCommandDispatcher? commandDispatcher;
private MxAccessSession? session;
private IAlarmCommandHandler? alarmCommandHandler;
private CancellationTokenSource? alarmPollCts;
private Task? alarmPollTask;
private int? alarmConsumerThreadId;
private bool disposed;
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with default dependencies.
/// </summary>
public MxAccessStaSession()
: this(
new StaRuntime(),
new MxAccessComObjectFactory(),
new MxAccessEventQueue())
{
}
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with default STA runtime,
/// factory, and event queue, but with a custom alarm-command handler factory. The factory is
/// invoked on the STA thread during
/// <see cref="StartAsync(string, int, CancellationToken)"/>; pass <c>null</c> to opt out
/// of alarm-side commands.
/// </summary>
internal MxAccessStaSession(Func<MxAccessEventQueue, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory)
: this(
new StaRuntime(),
new MxAccessComObjectFactory(),
new MxAccessEventQueue(),
alarmCommandHandlerFactory)
{
}
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom STA runtime and factory.
/// </summary>
/// <param name="staRuntime">STA thread runtime.</param>
/// <param name="factory">MXAccess COM object factory.</param>
/// <param name="eventSink">Event sink for MXAccess events.</param>
public MxAccessStaSession(
StaRuntime staRuntime,
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink)
: this(staRuntime, factory, eventSink, new MxAccessEventQueue())
{
}
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom event queue.
/// </summary>
/// <param name="staRuntime">STA thread runtime.</param>
/// <param name="factory">MXAccess COM object factory.</param>
/// <param name="eventQueue">Event queue for buffering MXAccess events.</param>
public MxAccessStaSession(
StaRuntime staRuntime,
IMxAccessComObjectFactory factory,
MxAccessEventQueue eventQueue)
: this(staRuntime, factory, new MxAccessBaseEventSink(eventQueue), eventQueue)
{
}
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom event queue
/// and an alarm-command handler factory.
/// </summary>
/// <param name="staRuntime">STA thread runtime.</param>
/// <param name="factory">MXAccess COM object factory.</param>
/// <param name="eventQueue">Event queue for buffering MXAccess events.</param>
/// <param name="alarmCommandHandlerFactory">
/// Factory that constructs the alarm-command handler from the event queue.
/// Pass <c>null</c> to opt out of alarm-side commands.
/// </param>
public MxAccessStaSession(
StaRuntime staRuntime,
IMxAccessComObjectFactory factory,
MxAccessEventQueue eventQueue,
Func<MxAccessEventQueue, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory)
: this(staRuntime, factory, new MxAccessBaseEventSink(eventQueue), eventQueue, alarmCommandHandlerFactory)
{
}
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with all dependencies.
/// </summary>
/// <param name="staRuntime">STA thread runtime.</param>
/// <param name="factory">MXAccess COM object factory.</param>
/// <param name="eventSink">Event sink for MXAccess events.</param>
/// <param name="eventQueue">Event queue for buffering MXAccess events.</param>
public MxAccessStaSession(
StaRuntime staRuntime,
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink,
MxAccessEventQueue eventQueue)
: this(staRuntime, factory, eventSink, eventQueue, alarmCommandHandlerFactory: null)
{
}
/// <summary>
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with all
/// dependencies including an alarm-command handler factory. The factory is
/// invoked on the STA thread during <see cref="StartAsync(string, int, CancellationToken)"/>;
/// pass <c>null</c> to opt out of alarm-side commands (the worker rejects
/// them with an "alarm consumer not configured" diagnostic).
/// </summary>
public MxAccessStaSession(
StaRuntime staRuntime,
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink,
MxAccessEventQueue eventQueue,
Func<MxAccessEventQueue, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory)
{
this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime));
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
this.alarmCommandHandlerFactory = alarmCommandHandlerFactory;
}
/// <summary>
/// Gets the event queue for this session.
/// </summary>
public MxAccessEventQueue EventQueue => eventQueue;
/// <summary>
/// Starts the MXAccess COM session asynchronously.
/// </summary>
/// <param name="workerProcessId">Worker process identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Worker ready message.</returns>
public Task<WorkerReady> StartAsync(
int workerProcessId,
CancellationToken cancellationToken = default)
{
return StartAsync(string.Empty, workerProcessId, cancellationToken);
}
/// <summary>
/// Starts the MXAccess COM session with a session ID asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="workerProcessId">Worker process identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Worker ready message.</returns>
public async Task<WorkerReady> StartAsync(
string sessionId,
int workerProcessId,
CancellationToken cancellationToken = default)
{
staRuntime.Start();
WorkerReady ready = await staRuntime.InvokeAsync(
() =>
{
if (session is not null)
{
throw new InvalidOperationException("MXAccess COM session has already been created.");
}
session = MxAccessSession.Create(factory, eventSink, sessionId);
if (alarmCommandHandlerFactory is not null)
{
// STA-affinity invariant: the alarm consumer factory and
// every IMxAccessAlarmConsumer call must run on the STA
// thread, because the production wnwrap consumer holds an
// Apartment-threaded COM object. The factory runs here
// inside staRuntime.InvokeAsync, so this records the STA
// thread id; RunAlarmPollLoopAsync then asserts each
// PollOnce executes on the same thread.
alarmConsumerThreadId = Environment.CurrentManagedThreadId;
// Worker-024: hand the handler an affinity guard so each
// of its command-path entries (Subscribe / Acknowledge /
// AcknowledgeByName / QueryActive / Unsubscribe / PollOnce)
// asserts the same STA-affinity invariant the poll path
// already enforced. Without this the command path relied
// on convention alone; a future refactor that let a
// command run off-STA would silently deadlock on
// cross-apartment marshaling against the wnwrap consumer.
alarmCommandHandler = alarmCommandHandlerFactory(
eventQueue,
EnsureOnAlarmConsumerThread);
}
commandDispatcher = new StaCommandDispatcher(
staRuntime,
new MxAccessCommandExecutor(
session,
new VariantConverter(),
alarmCommandHandler,
// ReadBulk needs to pump Windows messages while it waits
// for the first OnDataChange callback so the inbound COM
// event can dispatch on this same STA thread. The pump
// step closes over staRuntime so it always pumps the
// pump tied to the apartment that owns this session.
pumpStep: () => staRuntime.PumpPendingMessages()));
return session.CreateWorkerReady(workerProcessId);
},
cancellationToken).ConfigureAwait(false);
if (alarmCommandHandler is not null)
{
alarmPollCts = new CancellationTokenSource();
alarmPollTask = RunAlarmPollLoopAsync(alarmCommandHandler, alarmPollCts.Token);
}
return ready;
}
private Task RunAlarmPollLoopAsync(
IAlarmCommandHandler handler,
CancellationToken cancellationToken)
{
return Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(AlarmPollInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
if (cancellationToken.IsCancellationRequested)
{
return;
}
try
{
await staRuntime.InvokeAsync(
() =>
{
EnsureOnAlarmConsumerThread();
handler.PollOnce();
},
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (ObjectDisposedException)
{
// STA runtime or alarm handler disposed — stop the loop gracefully.
return;
}
catch (StaRuntimeShutdownException)
{
// STA runtime shutting down — stop the loop gracefully.
// The dedicated shutdown type lets us distinguish this
// graceful-stop signal from the STA-affinity assertion
// raised by EnsureOnAlarmConsumerThread (Worker-008),
// which is also an InvalidOperationException but signals
// a programming-error regression — that case falls through
// to the generic Exception arm below and is recorded as a
// fault on the event queue, so an affinity regression
// becomes observable on the IPC fault path instead of
// silently stopping alarm delivery.
return;
}
catch (Exception exception)
{
// A real alarm-poll failure (COMException from
// GetXmlCurrentAlarms2, malformed-XML parse failure, an
// STA-affinity InvalidOperationException from
// EnsureOnAlarmConsumerThread, etc.). Record it as a
// fault on the event queue so a broken alarm subscription
// — or an affinity-invariant regression — becomes
// observable on the IPC fault path instead of silently
// faulting this never-awaited task. The loop then stops —
// the subscription is dead.
eventQueue.RecordFault(CreateAlarmPollFault(exception));
return;
}
}
}, CancellationToken.None);
}
private void EnsureOnAlarmConsumerThread()
{
AssertOnAlarmConsumerThread(alarmConsumerThreadId, Environment.CurrentManagedThreadId);
}
/// <summary>
/// Enforces the STA-affinity invariant for the alarm consumer: every
/// <see cref="IMxAccessAlarmConsumer"/> call (and the consumer factory)
/// must run on the same thread the consumer was created on (the worker's
/// STA). Throws <see cref="InvalidOperationException"/> when a caller
/// breaks affinity — a programming error that would otherwise risk a
/// cross-apartment COM deadlock in the production wnwrap consumer, since
/// its CLSID is registered <c>ThreadingModel=Apartment</c>. The check is
/// a no-op until the consumer thread has been recorded (no alarm handler
/// configured, or session not yet started).
/// </summary>
/// <param name="expectedThreadId">
/// The managed thread id the alarm consumer was created on, or
/// <c>null</c> if no alarm consumer is configured.
/// </param>
/// <param name="actualThreadId">The current managed thread id.</param>
internal static void AssertOnAlarmConsumerThread(int? expectedThreadId, int actualThreadId)
{
if (expectedThreadId is not null && actualThreadId != expectedThreadId.Value)
{
throw new InvalidOperationException(
$"Alarm consumer accessed off its owning STA thread. Expected thread {expectedThreadId.Value}, "
+ $"actual {actualThreadId}. All IMxAccessAlarmConsumer calls must run on the STA that "
+ "created the consumer.");
}
}
private static WorkerFault CreateAlarmPollFault(Exception exception)
{
string message =
$"MXAccess alarm poll failed: {exception.Message}";
WorkerFault fault = new()
{
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
ExceptionType = exception.GetType().FullName ?? string.Empty,
DiagnosticMessage = message,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.WorkerUnavailable,
Message = message,
},
};
if (exception is System.Runtime.InteropServices.COMException comException)
{
fault.Hresult = comException.HResult;
}
return fault;
}
/// <summary>
/// Dispatches a command to the STA thread for execution asynchronously.
/// </summary>
/// <param name="command">The command to dispatch.</param>
/// <returns>Command reply.</returns>
public Task<MxCommandReply> DispatchAsync(StaCommand command)
{
if (commandDispatcher is null)
{
throw new InvalidOperationException("MXAccess COM session has not been started.");
}
return commandDispatcher.DispatchAsync(command);
}
/// <summary>
/// Captures a heartbeat snapshot of the session's runtime state.
/// </summary>
/// <returns>Heartbeat snapshot.</returns>
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
{
uint pendingCommandCount = 0;
string currentCommandCorrelationId = string.Empty;
if (commandDispatcher is not null)
{
pendingCommandCount = (uint)commandDispatcher.PendingCommandCount;
currentCommandCorrelationId = commandDispatcher.CurrentCommandCorrelationId;
}
return new WorkerRuntimeHeartbeatSnapshot(
staRuntime.LastActivityUtc,
pendingCommandCount,
(uint)eventQueue.Count,
eventQueue.LastEventSequence,
currentCommandCorrelationId);
}
/// <summary>
/// Requests graceful shutdown of the command dispatcher.
/// </summary>
public void RequestShutdown()
{
commandDispatcher?.RequestShutdown();
}
/// <summary>
/// Drains up to the specified number of events from the queue.
/// </summary>
/// <param name="maxEvents">Maximum events to drain.</param>
/// <returns>Drained events.</returns>
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
{
return eventQueue.Drain(maxEvents);
}
/// <summary>
/// Drains a fault from the queue if present.
/// </summary>
/// <returns>Drained fault or null.</returns>
public WorkerFault? DrainFault()
{
return eventQueue.DrainFault();
}
/// <summary>
/// Cancels a queued command by correlation ID.
/// </summary>
/// <param name="correlationId">Correlation ID of the command to cancel.</param>
/// <returns>True if cancelled; otherwise false.</returns>
public bool CancelCommand(string correlationId)
{
return commandDispatcher?.CancelQueuedCommand(correlationId) ?? false;
}
/// <summary>
/// Gets the registered server handles asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Registered server handles.</returns>
public Task<IReadOnlyList<RegisteredServerHandle>> GetRegisteredServerHandlesAsync(
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new InvalidOperationException("MXAccess COM session has not been started.");
}
return staRuntime.InvokeAsync(
() => session.HandleRegistry.ServerHandles,
cancellationToken);
}
/// <summary>
/// Gets the registered item handles asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Registered item handles.</returns>
public Task<IReadOnlyList<RegisteredItemHandle>> GetRegisteredItemHandlesAsync(
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new InvalidOperationException("MXAccess COM session has not been started.");
}
return staRuntime.InvokeAsync(
() => session.HandleRegistry.ItemHandles,
cancellationToken);
}
/// <summary>
/// Gets the registered advice handles asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Registered advice handles.</returns>
public Task<IReadOnlyList<RegisteredAdviceHandle>> GetRegisteredAdviceHandlesAsync(
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new InvalidOperationException("MXAccess COM session has not been started.");
}
return staRuntime.InvokeAsync(
() => session.HandleRegistry.AdviceHandles,
cancellationToken);
}
/// <summary>
/// Performs graceful shutdown of the MXAccess session within a timeout.
/// </summary>
/// <param name="timeout">Maximum time allowed for shutdown.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Shutdown result with any cleanup failures.</returns>
public async Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
nameof(timeout),
"MXAccess graceful shutdown timeout must be greater than zero.");
}
if (disposed)
{
return new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
}
commandDispatcher?.RequestShutdown();
// Cancel the STA poll loop before disposing the alarm handler.
// The loop references the alarm handler and must be stopped first
// so that no further PollOnce calls race with disposal.
CancellationTokenSource? pollCtsToDispose = alarmPollCts;
Task? pollTaskToAwait = alarmPollTask;
alarmPollCts = null;
alarmPollTask = null;
if (pollCtsToDispose is not null)
{
pollCtsToDispose.Cancel();
if (pollTaskToAwait is not null)
{
try
{
await pollTaskToAwait.ConfigureAwait(false);
}
catch
{
// Swallow — poll loop cancellation must not block data shutdown.
}
}
pollCtsToDispose.Dispose();
}
// Stop the alarm consumer's polling timer and tear down the
// dispatcher BEFORE the data-side cleanup begins. The alarm
// consumer holds a wnwrap COM RCW that needs the STA pump to
// unwind cleanly; doing it here gives it the opportunity while
// the STA is still alive.
IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler;
alarmCommandHandler = null;
if (alarmHandlerToDispose is not null)
{
try
{
await staRuntime.InvokeAsync(
() => alarmHandlerToDispose.Dispose(),
cancellationToken).ConfigureAwait(false);
}
catch
{
// Swallow — alarm cleanup must not block data shutdown.
}
}
Stopwatch stopwatch = Stopwatch.StartNew();
MxAccessShutdownResult result;
if (session is null)
{
result = new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
}
else
{
using CancellationTokenSource shutdownCancellation =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
shutdownCancellation.CancelAfter(timeout);
Task<MxAccessShutdownResult> cleanupTask = staRuntime.InvokeAsync(
() => session.ShutdownGracefully(),
shutdownCancellation.Token);
Task delayTask = Task.Delay(timeout, cancellationToken);
Task completedTask = await Task.WhenAny(cleanupTask, delayTask).ConfigureAwait(false);
if (completedTask != cleanupTask)
{
cancellationToken.ThrowIfCancellationRequested();
throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}.");
}
try
{
result = await cleanupTask.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}.");
}
}
TimeSpan remaining = timeout - stopwatch.Elapsed;
if (remaining <= TimeSpan.Zero || !staRuntime.Shutdown(remaining))
{
throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}.");
}
staRuntime.Dispose();
disposed = true;
return result;
}
/// <summary>Releases resources and shuts down the session.</summary>
public void Dispose()
{
if (disposed)
{
return;
}
RequestShutdown();
// Cancel the STA poll loop and join it before disposing the alarm
// handler. Joining (rather than discarding alarmPollTask) makes the
// stop deterministic: once Dispose returns, no further PollOnce calls
// can be in flight, so callers and tests can rely on a frozen poll
// count instead of an elapsed-time "no further polls" window.
CancellationTokenSource? pollCtsToDispose = alarmPollCts;
Task? pollTaskToJoin = alarmPollTask;
alarmPollCts = null;
alarmPollTask = null;
if (pollCtsToDispose is not null)
{
try { pollCtsToDispose.Cancel(); } catch { }
if (pollTaskToJoin is not null)
{
try
{
pollTaskToJoin.Wait(TimeSpan.FromSeconds(5));
}
catch (AggregateException) { }
catch (ObjectDisposedException) { }
}
try { pollCtsToDispose.Dispose(); } catch { }
}
IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler;
alarmCommandHandler = null;
if (alarmHandlerToDispose is not null)
{
try
{
staRuntime.InvokeAsync(() => alarmHandlerToDispose.Dispose())
.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException) { }
catch (ObjectDisposedException) { }
}
if (session is not null)
{
try
{
staRuntime.InvokeAsync(() => session.Dispose())
.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException)
{
}
catch (ObjectDisposedException)
{
}
}
staRuntime.Dispose();
disposed = true;
}
}
@@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Google.Protobuf.Collections;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Per-session cache of the most recent <c>OnDataChange</c> payload for
/// each (server handle, item handle) pair. Written by the MXAccess event
/// sink as new OnDataChange callbacks arrive; read by the ReadBulk command
/// executor so it can satisfy a "current value" request from a tag that is
/// already advised without modifying the existing subscription.
/// </summary>
/// <remarks>
/// Both writers and readers run on the worker's STA thread (COM dispatches
/// events on the apartment thread; commands also execute on the STA), so
/// no internal locking is required. The class is still nominally
/// thread-safe via a single sync root in case tests drive it from a
/// non-STA thread.
/// </remarks>
public sealed class MxAccessValueCache
{
private readonly Dictionary<long, CachedValue> entries = new();
private readonly object syncRoot = new();
/// <summary>Records a fresh OnDataChange payload for the given handle pair.</summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <param name="mxEvent">The protobuf MxEvent created by the event mapper.</param>
public void Set(
int serverHandle,
int itemHandle,
MxEvent mxEvent)
{
if (mxEvent is null)
{
throw new ArgumentNullException(nameof(mxEvent));
}
long key = CreateItemKey(serverHandle, itemHandle);
lock (syncRoot)
{
ulong nextVersion = entries.TryGetValue(key, out CachedValue existing)
? existing.Version + 1
: 1UL;
entries[key] = new CachedValue(
nextVersion,
mxEvent.Value,
mxEvent.Quality,
mxEvent.SourceTimestamp,
mxEvent.Statuses);
}
}
/// <summary>Tries to read the most recent cached value for the handle pair.</summary>
public bool TryGet(
int serverHandle,
int itemHandle,
out CachedValue value)
{
long key = CreateItemKey(serverHandle, itemHandle);
lock (syncRoot)
{
return entries.TryGetValue(key, out value);
}
}
/// <summary>
/// Removes the cache slot for a handle pair. The session calls this
/// when an item is unregistered so stale values are not served to a
/// subsequent ReadBulk after a tag is removed and re-added.
/// </summary>
public void Remove(
int serverHandle,
int itemHandle)
{
long key = CreateItemKey(serverHandle, itemHandle);
lock (syncRoot)
{
entries.Remove(key);
}
}
/// <summary>
/// Waits until the cache entry's version exceeds <paramref name="sinceVersion"/>
/// or the deadline elapses, calling <paramref name="pumpStep"/> on every poll
/// iteration so the worker's STA can dispatch the inbound MXAccess message.
/// </summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <param name="sinceVersion">Version snapshot captured before the wait.</param>
/// <param name="deadlineUtc">Absolute UTC deadline.</param>
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
/// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
public bool TryWaitForUpdate(
int serverHandle,
int itemHandle,
ulong sinceVersion,
DateTime deadlineUtc,
Action pumpStep,
out CachedValue value,
int pollIntervalMs = 5)
{
if (pumpStep is null)
{
throw new ArgumentNullException(nameof(pumpStep));
}
while (true)
{
pumpStep();
if (TryGet(serverHandle, itemHandle, out value) && value.Version > sinceVersion)
{
return true;
}
if (DateTime.UtcNow >= deadlineUtc)
{
return false;
}
Thread.Sleep(pollIntervalMs);
}
}
/// <summary>Returns the current version for a handle pair, or 0 if no entry exists.</summary>
public ulong CurrentVersion(
int serverHandle,
int itemHandle)
{
return TryGet(serverHandle, itemHandle, out CachedValue existing)
? existing.Version
: 0UL;
}
private static long CreateItemKey(
int serverHandle,
int itemHandle)
{
return ((long)serverHandle << 32) | (uint)itemHandle;
}
/// <summary>
/// Snapshot of the most recent OnDataChange payload for a handle pair.
/// <see cref="Version"/> increments by one on every <see cref="Set"/>
/// call so the bulk read executor can detect "a new value arrived
/// since I started waiting".
/// </summary>
/// <remarks>
/// Plain readonly struct (not a record) so this compiles under the
/// worker's net48 target, which lacks <c>IsExternalInit</c>.
/// </remarks>
public readonly struct CachedValue
{
/// <summary>Initializes a new cached value snapshot.</summary>
public CachedValue(
ulong version,
MxValue value,
int quality,
Timestamp sourceTimestamp,
RepeatedField<MxStatusProxy> statuses)
{
Version = version;
Value = value;
Quality = quality;
SourceTimestamp = sourceTimestamp;
Statuses = statuses;
}
/// <summary>Monotonic per-handle version counter.</summary>
public ulong Version { get; }
/// <summary>The cached MxValue payload.</summary>
public MxValue Value { get; }
/// <summary>Quality code from the OnDataChange event.</summary>
public int Quality { get; }
/// <summary>Source timestamp from the OnDataChange event.</summary>
public Timestamp SourceTimestamp { get; }
/// <summary>MxStatusProxy entries from the OnDataChange event.</summary>
public RepeatedField<MxStatusProxy> Statuses { get; }
}
}
@@ -0,0 +1,26 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Single alarm record as emitted by the wnwrapConsumer XML stream.
/// Field names match the captured XML schema (see
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" section).
/// </summary>
public sealed class MxAlarmSnapshotRecord
{
public Guid AlarmGuid { get; set; }
public DateTime TransitionTimestampUtc { get; set; }
public string ProviderNode { get; set; } = string.Empty;
public string ProviderName { get; set; } = string.Empty;
public string Group { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Limit { get; set; } = string.Empty;
public int Priority { get; set; }
public MxAlarmStateKind State { get; set; }
public string OperatorNode { get; set; } = string.Empty;
public string OperatorName { get; set; } = string.Empty;
public string AlarmComment { get; set; } = string.Empty;
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Library-agnostic alarm-state enum. Mirrors the four <c>STATE</c>
/// values returned by AVEVA's <c>WNWRAPCONSUMERLib</c> XML payload —
/// <c>UNACK_ALM</c>, <c>ACK_ALM</c>, <c>UNACK_RTN</c>, <c>ACK_RTN</c>.
/// Decoupling the consumer from any specific COM library keeps the
/// proto-build path testable without an AVEVA install.
/// </summary>
public enum MxAlarmStateKind
{
Unspecified = 0,
UnackAlm = 1,
AckAlm = 2,
UnackRtn = 3,
AckRtn = 4,
}
@@ -0,0 +1,20 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// One transition emitted by the consumer's snapshot diff. Pairs the
/// latest record with its previous state so the proto layer can decide
/// whether the transition is a Raise / Acknowledge / Clear.
/// </summary>
public sealed class MxAlarmTransitionEvent : EventArgs
{
public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord();
/// <summary>
/// The state on the consumer's previous polled snapshot, or
/// <see cref="MxAlarmStateKind.Unspecified"/> when this is the
/// first time the GUID has been observed.
/// </summary>
public MxAlarmStateKind PreviousState { get; set; }
}
@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class RegisteredAdviceHandle
{
/// <summary>Initializes a new instance of the <see cref="RegisteredAdviceHandle"/> class.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="adviceKind">Type of advice.</param>
public RegisteredAdviceHandle(
int serverHandle,
int itemHandle,
MxAccessAdviceKind adviceKind)
{
ServerHandle = serverHandle;
ItemHandle = itemHandle;
AdviceKind = adviceKind;
}
/// <summary>Gets the server handle.</summary>
public int ServerHandle { get; }
/// <summary>Gets the item handle.</summary>
public int ItemHandle { get; }
/// <summary>Gets the advice kind.</summary>
public MxAccessAdviceKind AdviceKind { get; }
}
@@ -0,0 +1,54 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>
/// Metadata for an item handle registered in an MXAccess session.
/// </summary>
public sealed class RegisteredItemHandle
{
/// <summary>
/// Initializes a registered item handle with complete metadata.
/// </summary>
/// <param name="serverHandle">Handle returned by Register.</param>
/// <param name="itemHandle">Handle returned by AddItem.</param>
/// <param name="itemDefinition">Item definition (tag address).</param>
/// <param name="itemContext">Item context string.</param>
/// <param name="hasItemContext">Whether this item has an associated context.</param>
public RegisteredItemHandle(
int serverHandle,
int itemHandle,
string itemDefinition,
string itemContext,
bool hasItemContext)
{
ServerHandle = serverHandle;
ItemHandle = itemHandle;
ItemDefinition = itemDefinition;
ItemContext = itemContext;
HasItemContext = hasItemContext;
}
/// <summary>
/// Gets the server handle that owns this item.
/// </summary>
public int ServerHandle { get; }
/// <summary>
/// Gets the item handle within the server.
/// </summary>
public int ItemHandle { get; }
/// <summary>
/// Gets the item definition (tag address).
/// </summary>
public string ItemDefinition { get; }
/// <summary>
/// Gets the item context.
/// </summary>
public string ItemContext { get; }
/// <summary>
/// Gets a value indicating whether this item has an associated context.
/// </summary>
public bool HasItemContext { get; }
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class RegisteredServerHandle
{
/// <summary>Initializes a new registered server handle.</summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="clientName">Client name associated with the handle.</param>
public RegisteredServerHandle(
int serverHandle,
string clientName)
{
ServerHandle = serverHandle;
ClientName = clientName;
}
/// <summary>The MXAccess server handle.</summary>
public int ServerHandle { get; }
/// <summary>The client name associated with this server handle.</summary>
public string ClientName { get; }
}
@@ -0,0 +1,569 @@
using System;
using System.Collections.Generic;
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(() =&gt; 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;
private readonly object syncRoot = new object();
private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
new Dictionary<Guid, MxAlarmSnapshotRecord>();
private readonly int maxAlarmsPerFetch;
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(() =&gt; consumer.PollOnce())</c> so that
/// every COM call stays on the STA that owns the apartment.
/// </summary>
public WnWrapAlarmConsumer()
: this(new wwAlarmConsumerClass(), DefaultMaxAlarmsPerFetch)
{
}
/// <summary>
/// Test seam / explicit construction.
/// </summary>
public WnWrapAlarmConsumer(
wwAlarmConsumerClass client,
int maxAlarmsPerFetch)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
? maxAlarmsPerFetch
: DefaultMaxAlarmsPerFetch;
}
/// <inheritdoc />
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. The v1 prefix path is what WIN-911-style code
// uses against the same wnwrap library.
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 />
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;
}
}
/// <summary>
/// Synchronously poll the wnwrap consumer once and dispatch any
/// transitions. STA-bound hosts drive polling by calling this from
/// the thread that owns the COM object. The consumer deliberately
/// owns no internal timer: a thread-pool timer would call the
/// apartment-threaded COM object off its owning STA and can block
/// indefinitely on cross-apartment marshaling when the STA is not
/// pumping messages.
/// </summary>
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);
IReadOnlyList<MxAlarmTransitionEvent> transitions;
lock (syncRoot)
{
transitions = ComputeTransitions(latestSnapshot, next);
latestSnapshot.Clear();
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
{
latestSnapshot[kv.Key] = kv.Value;
}
}
if (transitions.Count == 0) return;
EventHandler<MxAlarmTransitionEvent>? handler = AlarmTransitionEmitted;
if (handler is null) return;
foreach (MxAlarmTransitionEvent transition in transitions)
{
handler.Invoke(this, transition);
}
}
/// <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 (Worker.Tests-022).
/// </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>
/// </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>
public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
{
Dictionary<Guid, MxAlarmSnapshotRecord> records =
new Dictionary<Guid, MxAlarmSnapshotRecord>();
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;
foreach (XmlNode alarmNode in alarmNodes)
{
string guidHex = TextOf(alarmNode, "GUID");
if (!TryParseHexGuid(guidHex, out Guid guid)) continue;
string xmlDate = TextOf(alarmNode, "DATE");
string xmlTime = TextOf(alarmNode, "TIME");
int gmtOffset = ParseInt(TextOf(alarmNode, "GMTOFFSET"));
int dstAdjust = ParseInt(TextOf(alarmNode, "DSTADJUST"));
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
xmlDate, xmlTime, gmtOffset, dstAdjust);
records[guid] = new MxAlarmSnapshotRecord
{
AlarmGuid = guid,
TransitionTimestampUtc = tsUtc,
ProviderNode = TextOf(alarmNode, "PROVIDER_NODE"),
ProviderName = TextOf(alarmNode, "PROVIDER_NAME"),
Group = TextOf(alarmNode, "GROUP"),
TagName = TextOf(alarmNode, "TAGNAME"),
Type = TextOf(alarmNode, "TYPE"),
Value = TextOf(alarmNode, "VALUE"),
Limit = TextOf(alarmNode, "LIMIT"),
Priority = ParseInt(TextOf(alarmNode, "PRIORITY")),
State = AlarmRecordTransitionMapper.ParseStateKind(TextOf(alarmNode, "STATE")),
OperatorNode = TextOf(alarmNode, "OPERATOR_NODE"),
OperatorName = TextOf(alarmNode, "OPERATOR_NAME"),
AlarmComment = TextOf(alarmNode, "ALARM_COMMENT"),
};
}
return records;
}
private static string TextOf(XmlNode parent, string childName)
{
XmlNode? node = parent.SelectSingleNode(childName);
return node?.InnerText ?? string.Empty;
}
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>
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>\\&lt;machine&gt;\Galaxy!&lt;area&gt;</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>
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 */ }
}
}
}
@@ -0,0 +1,41 @@
using System;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class WorkerRuntimeHeartbeatSnapshot
{
/// <summary>Initializes a new instance of the <see cref="WorkerRuntimeHeartbeatSnapshot"/> class.</summary>
/// <param name="lastStaActivityUtc">Timestamp of the last STA thread activity in UTC.</param>
/// <param name="pendingCommandCount">Number of commands awaiting processing.</param>
/// <param name="outboundEventQueueDepth">Current depth of the worker event queue.</param>
/// <param name="lastEventSequence">Sequence number of the most recent event.</param>
/// <param name="currentCommandCorrelationId">Correlation ID of the in-flight command.</param>
public WorkerRuntimeHeartbeatSnapshot(
DateTimeOffset lastStaActivityUtc,
uint pendingCommandCount,
uint outboundEventQueueDepth,
ulong lastEventSequence,
string currentCommandCorrelationId)
{
LastStaActivityUtc = lastStaActivityUtc;
PendingCommandCount = pendingCommandCount;
OutboundEventQueueDepth = outboundEventQueueDepth;
LastEventSequence = lastEventSequence;
CurrentCommandCorrelationId = currentCommandCorrelationId ?? string.Empty;
}
/// <summary>Gets the last STA activity timestamp in UTC.</summary>
public DateTimeOffset LastStaActivityUtc { get; }
/// <summary>Gets the pending command count.</summary>
public uint PendingCommandCount { get; }
/// <summary>Gets the current depth of the worker event queue.</summary>
public uint OutboundEventQueueDepth { get; }
/// <summary>Gets the sequence number of the most recent event.</summary>
public ulong LastEventSequence { get; }
/// <summary>Gets the correlation ID of the in-flight command.</summary>
public string CurrentCommandCorrelationId { get; }
}