diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs
new file mode 100644
index 0000000..d6c3cfa
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmSubtagLiveSmokeTests.cs
@@ -0,0 +1,435 @@
+// AlarmSubtagLiveSmokeTests.cs
+//
+// Validates the open design item from Task 17: exact subtag names and the
+// canonical AlarmSubtagTarget reference shape for a known Galaxy alarm.
+//
+// This test exercises the full subtag-fallback pipeline end-to-end against
+// a real Galaxy + MXAccess install:
+// LmxSubtagAlarmSource (own LMXProxyServerClass) ->
+// SubtagAlarmConsumer (state machine + AcknowledgeByName write) ->
+// synthesized MxAlarmTransitionEvent (Raise / Clear, Degraded=true, SyntheticGuid)
+//
+// HOW TO RUN:
+// 1. On the dev rig with AVEVA System Platform installed and Galaxy running:
+// $env:MXGATEWAY_RUN_LIVE_MXACCESS_TESTS = "1"
+// 2. Remove (or set to null) the Skip parameter on the [Fact] below.
+// 3. Run with an alarm flip script (same one used by AlarmsLiveSmokeTests)
+// so that TestMachine_001.TestAlarm001 toggles its Active/Acked subtags
+// on a ~10 s cadence.
+// 4. VERIFY the placeholder subtag names (PLACEHOLDER_* constants below)
+// against the live Galaxy before flipping Skip -- see VERIFY comment block.
+//
+// OPEN ITEM (Task 17): the exact subtag address strings are not yet confirmed
+// from a live Galaxy or from C:\Users\dohertj2\Desktop\mxaccess
+// docs/MXAccess-Public-API.md. Flip this test only after verifying them.
+//
+// net48/x86 constraints:
+// - No init-only properties, records, index/range operators, C# 8+ pattern
+// matches beyond what the existing Worker.Tests files use.
+// - All Booleans from MXAccess arrive as boxed int (0 / non-zero); coerce
+// with Convert.ToBoolean or cast to int first.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Threading;
+using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Worker.MxAccess;
+using Xunit.Abstractions;
+
+namespace ZB.MOM.WW.MxGateway.Worker.Tests.Probes;
+
+///
+/// Live dev-rig smoke test for the subtag-fallback alarm pipeline.
+/// Validates the open design item from Task 17: confirms that
+/// wired to a real
+/// LMXProxyServerClass + can
+/// synthesize records from Galaxy
+/// alarm subtags, and that
+/// writes the ack-comment subtag successfully.
+///
+/// Skip-gated; flip Skip=null on the dev rig with the alarm flip
+/// script running. VERIFY the PLACEHOLDER_* subtag names before running.
+///
+public sealed class AlarmSubtagLiveSmokeTests
+{
+ // -------------------------------------------------------------------------
+ // PLACEHOLDER references — VERIFY before flipping Skip.
+ //
+ // These values are the best-guess subtag addresses for TestMachine_001.TestAlarm001
+ // as used by the existing AlarmsLiveSmokeTests (which watches the same Galaxy
+ // provider). The exact attribute suffix strings (.InAlarm, .Acked,
+ // .AckComment, .Priority) MUST be confirmed against:
+ // - C:\Users\dohertj2\Desktop\mxaccess\docs\MXAccess-Public-API.md
+ // - A live Galaxy attribute browse or MXTraceHarness capture
+ // before this test is flipped on. The subtag address format is
+ // .
+ // where the alarm attribute names are Galaxy-attribute names surfaced
+ // through MXAccess. Known AVEVA attribute names are .InAlarm (bool),
+ // .Acked (bool), .Priority (int), and a writable comment attribute whose
+ // exact name varies by alarm template (commonly .AlarmComment or
+ // .AckComment).
+ // -------------------------------------------------------------------------
+
+ /// The Galaxy provider expression used by the existing live smoke tests.
+ private static readonly string Provider =
+ string.Format(@"\\{0}\Galaxy", Environment.MachineName);
+
+ /// The Galaxy group (provider sub-path) containing the test alarm.
+ private const string Group = "DEV";
+
+ /// The test alarm's tag name as the dispatcher composes it (Group.TagName).
+ private const string AlarmTagName = "TestMachine_001.TestAlarm001";
+
+ // PLACEHOLDER — VERIFY against MXAccess-Public-API.md and live Galaxy.
+ // Typical InProcess Platform alarm active subtag: ".InAlarm"
+ private const string PlaceholderActiveSubtag = "TestMachine_001.TestAlarm001.InAlarm";
+
+ // PLACEHOLDER — VERIFY. Typical: ".Acked"
+ private const string PlaceholderAckedSubtag = "TestMachine_001.TestAlarm001.Acked";
+
+ // PLACEHOLDER — VERIFY. The writable ack-comment attribute varies by alarm
+ // template; common names are .AlarmComment, .AckComment, or .OperComment.
+ // Use a live Galaxy attribute browse (InTouch AlarmDB or MXTraceHarness
+ // GetItemList) to confirm before flipping.
+ private const string PlaceholderAckCommentSubtag = "TestMachine_001.TestAlarm001.AlarmComment";
+
+ // PLACEHOLDER — VERIFY. Typically: ".Priority"
+ private const string PlaceholderPrioritySubtag = "TestMachine_001.TestAlarm001.Priority";
+
+ // -------------------------------------------------------------------------
+
+ private static readonly TimeSpan RaiseWaitTimeout = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan ClearWaitTimeout = TimeSpan.FromSeconds(30);
+ private static readonly string AlarmFullReference =
+ AlarmRecordTransitionMapper.ComposeFullReference(Provider, Group, AlarmTagName);
+
+ private readonly ITestOutputHelper output;
+ private readonly Stopwatch elapsed = Stopwatch.StartNew();
+ private readonly ConcurrentQueue log =
+ new ConcurrentQueue();
+
+ /// Initializes a new instance of the AlarmSubtagLiveSmokeTests class.
+ /// Test output helper for logging.
+ public AlarmSubtagLiveSmokeTests(ITestOutputHelper output)
+ {
+ this.output = output;
+ }
+
+ ///
+ /// Verifies the subtag-fallback pipeline: advises alarm subtags through a
+ /// real LMXProxyServerClass, collects a Raise then a Clear
+ /// transition synthesized by , confirms
+ /// the Degraded flag and synthetic GUID are stamped, then
+ /// AcknowledgeByName and verifies the ack-comment write returns 0.
+ ///
+ [Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + an alarm flip script running. Subtag fallback path. VERIFY PLACEHOLDER_* subtag names before enabling.")]
+ public void SubtagFallback_FullPipelineRoundTrip_SynthesizesRaiseAndAcknowledges()
+ {
+ Exception? threadException = null;
+ ManualResetEventSlim done = new ManualResetEventSlim(false);
+ Thread thread = new Thread(() =>
+ {
+ try { RunSmoke(); }
+ catch (Exception ex) { threadException = ex; }
+ finally { done.Set(); }
+ });
+ thread.IsBackground = false;
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ done.Wait();
+ thread.Join();
+
+ output.WriteLine(string.Format("Captured {0} log line(s):", log.Count));
+ string? line;
+ while (log.TryDequeue(out line))
+ {
+ output.WriteLine(line);
+ }
+
+ if (threadException != null)
+ {
+ throw threadException;
+ }
+ }
+
+ private void RunSmoke()
+ {
+ Log(string.Format("AlarmFullReference: {0}", AlarmFullReference));
+ Log("VERIFY: PlaceholderActiveSubtag = " + PlaceholderActiveSubtag);
+ Log("VERIFY: PlaceholderAckedSubtag = " + PlaceholderAckedSubtag);
+ Log("VERIFY: PlaceholderAckCommentSubtag = " + PlaceholderAckCommentSubtag);
+ Log("VERIFY: PlaceholderPrioritySubtag = " + PlaceholderPrioritySubtag);
+ Log(string.Format("RaiseWaitTimeout={0}s ClearWaitTimeout={1}s",
+ RaiseWaitTimeout.TotalSeconds, ClearWaitTimeout.TotalSeconds));
+
+ // Build target with PLACEHOLDER subtag names.
+ // VERIFY these against MXAccess-Public-API.md and a live Galaxy
+ // attribute browse before flipping Skip=null.
+ AlarmSubtagTarget target = new AlarmSubtagTarget
+ {
+ AlarmFullReference = AlarmFullReference,
+ SourceObjectReference = AlarmTagName,
+ ActiveSubtag = PlaceholderActiveSubtag,
+ AckedSubtag = PlaceholderAckedSubtag,
+ AckCommentSubtag = PlaceholderAckCommentSubtag,
+ PrioritySubtag = PlaceholderPrioritySubtag,
+ };
+
+ List watchList = new List { target };
+
+ // Construct the real COM-backed subtag source using the production
+ // factory (MxAccessComObjectFactory -> new LMXProxyServerClass()).
+ // This is the same factory the worker uses in production; no test
+ // double is involved on this path.
+ MxAccessComObjectFactory factory = new MxAccessComObjectFactory();
+ LmxSubtagAlarmSource source = new LmxSubtagAlarmSource(factory, clientName: null);
+
+ // SubtagAlarmConsumer wraps the source and drives the state machine.
+ using SubtagAlarmConsumer consumer = new SubtagAlarmConsumer(source, watchList);
+
+ // Collect emitted transitions on the STA (handler fires on the same
+ // STA that services the COM OnDataChange callback).
+ ConcurrentQueue transitions =
+ new ConcurrentQueue();
+ consumer.AlarmTransitionEmitted += (_, e) =>
+ {
+ Log(string.Format("Transition emitted: {0}", DescribeTransition(e)));
+ transitions.Enqueue(e);
+ };
+
+ // Subscribe binds Advise on all observable subtags (Active, Acked,
+ // Priority). The subscription expression is unused in subtag mode; pass
+ // something recognizable for diagnostics.
+ string subscriptionExpression = string.Format(@"\\{0}\Galaxy!{1}", Environment.MachineName, Group);
+ Log(string.Format("Calling Subscribe({0}) ...", subscriptionExpression));
+ consumer.Subscribe(subscriptionExpression);
+ Log("Subscribe returned OK.");
+
+ // 1. Wait for a Raise transition. The alarm flip script (same one
+ // used by AlarmsLiveSmokeTests) writes the active subtag on a
+ // ~10 s cadence. LmxSubtagAlarmSource delivers OnDataChange via
+ // the Windows message pump on the STA, so we must pump messages
+ // here while we wait — mirroring how AlarmsLiveSmokeTests drives
+ // its WnWrapAlarmConsumer.PollOnce() from the STA in a tight loop.
+ Log(string.Format("Waiting up to {0}s for a Raise transition ...", RaiseWaitTimeout.TotalSeconds));
+ MxAlarmTransitionEvent? raiseEvent = WaitForTransitionKind(
+ transitions, AlarmTransitionKind.Raise, RaiseWaitTimeout, "Raise");
+
+ Assert.NotNull(raiseEvent);
+ Assert.True(raiseEvent!.Record.Degraded,
+ "Subtag-synthesized records must have Degraded=true.");
+ Assert.NotEqual(Guid.Empty, raiseEvent.Record.AlarmGuid,
+ "SubtagAlarmConsumer must stamp a synthetic GUID on the transition.");
+ Assert.Equal(MxAlarmStateKind.UnackAlm, raiseEvent.Record.State,
+ "A Raise transition must leave the record in UnackAlm state.");
+ Log(string.Format("Raise confirmed: AlarmGuid={0} Degraded={1} State={2}",
+ raiseEvent.Record.AlarmGuid, raiseEvent.Record.Degraded, raiseEvent.Record.State));
+
+ // 2. Snapshot active alarms and confirm the raised alarm is present.
+ IReadOnlyList snapshot = consumer.SnapshotActiveAlarms();
+ Log(string.Format("SnapshotActiveAlarms count={0}", snapshot.Count));
+ foreach (MxAlarmSnapshotRecord s in snapshot)
+ {
+ Log(string.Format(" snapshot: TagName='{0}' Group='{1}' State={2} Degraded={3} Guid={4}",
+ s.TagName, s.Group, s.State, s.Degraded, s.AlarmGuid));
+ }
+
+ bool foundInSnapshot = false;
+ foreach (MxAlarmSnapshotRecord s in snapshot)
+ {
+ if (string.Equals(
+ AlarmRecordTransitionMapper.ComposeFullReference(s.ProviderName, s.Group, s.TagName),
+ AlarmFullReference,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ foundInSnapshot = true;
+ break;
+ }
+ }
+
+ Assert.True(foundInSnapshot,
+ string.Format("Active alarm snapshot must contain '{0}' after a Raise.", AlarmFullReference));
+
+ // 3. AcknowledgeByName — writes the ack-comment subtag.
+ // The dispatcher derives (alarmName, provider, group) via the same
+ // TryParseReference logic as AlarmsLiveSmokeTests.
+ Log("Calling AcknowledgeByName ...");
+ int rc = consumer.AcknowledgeByName(
+ alarmName: AlarmTagName,
+ providerName: Provider,
+ groupName: Group,
+ ackComment: "subtag-fallback-smoke ack",
+ ackOperatorName: Environment.UserName,
+ ackOperatorNode: Environment.MachineName,
+ ackOperatorDomain: Environment.UserDomainName ?? string.Empty,
+ ackOperatorFullName: Environment.UserName);
+
+ Log(string.Format("AcknowledgeByName -> rc={0}", rc));
+ Assert.Equal(0, rc);
+
+ // 4. Wait for a Clear or Acknowledge transition to confirm the state
+ // machine continues tracking after the ack write.
+ Log(string.Format("Waiting up to {0}s for a Clear or Acknowledge transition ...",
+ ClearWaitTimeout.TotalSeconds));
+ MxAlarmTransitionEvent? postAckEvent = WaitForAnyTransition(transitions, ClearWaitTimeout, "post-ack");
+
+ // A null here is not a hard failure: the test alarm may not have
+ // cleared within the window, and the ack-comment write already
+ // confirmed the subtag path is wired. Log and assert non-null
+ // only when the post-ack transition is expected.
+ if (postAckEvent != null)
+ {
+ AlarmTransitionKind postAckKind = AlarmRecordTransitionMapper.MapTransition(
+ postAckEvent.PreviousState, postAckEvent.Record.State);
+ Log(string.Format("Post-ack transition: {0}", DescribeTransition(postAckEvent)));
+ Assert.NotEqual(AlarmTransitionKind.Unspecified, postAckKind);
+ }
+ else
+ {
+ Log("No post-ack transition within timeout — ack-comment write succeeded; state machine still live.");
+ }
+
+ Log("Smoke test complete.");
+ }
+
+ private MxAlarmTransitionEvent? WaitForTransitionKind(
+ ConcurrentQueue queue,
+ AlarmTransitionKind kind,
+ TimeSpan timeout,
+ string label)
+ {
+ DateTime deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ // Pump the STA Windows message queue so COM OnDataChange callbacks
+ // can be delivered. LMXProxyServerClass is apartment-threaded and
+ // requires the STA to be pumping; a bare Thread.Sleep would stall
+ // the pump.
+ PumpMessages();
+
+ MxAlarmTransitionEvent? evt;
+ if (queue.TryDequeue(out evt) && evt != null)
+ {
+ AlarmTransitionKind evtKind = AlarmRecordTransitionMapper.MapTransition(
+ evt.PreviousState, evt.Record.State);
+ if (evtKind == kind)
+ {
+ return evt;
+ }
+
+ Log(string.Format("Skipped transition kind={0} while waiting for {1}.", evtKind, label));
+ // Can't re-enqueue; log and continue.
+ }
+
+ Thread.Sleep(250);
+ }
+
+ Log(string.Format("Timed out waiting for {0} transition after {1}s.", label, timeout.TotalSeconds));
+ return null;
+ }
+
+ private MxAlarmTransitionEvent? WaitForAnyTransition(
+ ConcurrentQueue queue,
+ TimeSpan timeout,
+ string label)
+ {
+ DateTime deadline = DateTime.UtcNow + timeout;
+ while (DateTime.UtcNow < deadline)
+ {
+ PumpMessages();
+
+ MxAlarmTransitionEvent? evt;
+ if (queue.TryDequeue(out evt) && evt != null)
+ {
+ return evt;
+ }
+
+ Thread.Sleep(250);
+ }
+
+ Log(string.Format("Timed out waiting for any transition ({0}) after {1}s.", label, timeout.TotalSeconds));
+ return null;
+ }
+
+ ///
+ /// Runs a single pass of the Windows STA message pump using a
+ /// non-blocking PeekMessage/DispatchMessage loop so COM
+ /// OnDataChange callbacks from LMXProxyServerClass
+ /// (ThreadingModel=Apartment) can be delivered on this thread.
+ /// Mirrors the pump pattern documented in
+ /// docs/MxAccessWorkerInstanceDesign.md.
+ ///
+ private static void PumpMessages()
+ {
+ NativeMethods.MSG msg;
+ // Drain all currently posted messages; return as soon as the queue
+ // is empty.
+ while (NativeMethods.PeekMessage(out msg, IntPtr.Zero, 0, 0, NativeMethods.PM_REMOVE))
+ {
+ NativeMethods.TranslateMessage(ref msg);
+ NativeMethods.DispatchMessage(ref msg);
+ }
+ }
+
+ private static string DescribeTransition(MxAlarmTransitionEvent e)
+ {
+ AlarmTransitionKind kind = AlarmRecordTransitionMapper.MapTransition(
+ e.PreviousState, e.Record.State);
+ return string.Format(
+ "Kind={0} PreviousState={1} State={2} TagName='{3}' Group='{4}' Provider='{5}' Degraded={6} Guid={7}",
+ kind,
+ e.PreviousState,
+ e.Record.State,
+ e.Record.TagName,
+ e.Record.Group,
+ e.Record.ProviderName,
+ e.Record.Degraded,
+ e.Record.AlarmGuid);
+ }
+
+ private void Log(string line)
+ {
+ log.Enqueue(string.Format("[t={0:F3}s] {1}", elapsed.Elapsed.TotalSeconds, line));
+ }
+
+ // -------------------------------------------------------------------------
+ // Minimal P/Invoke shim so the STA pump can be driven without pulling in
+ // the full StaRuntime machinery from the Worker project. The signatures
+ // mirror those in MxAccessStaRuntime and are well-known Win32.
+ // -------------------------------------------------------------------------
+ private static class NativeMethods
+ {
+ internal const uint PM_REMOVE = 0x0001;
+
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct MSG
+ {
+ internal IntPtr hwnd;
+ internal uint message;
+ internal IntPtr wParam;
+ internal IntPtr lParam;
+ internal uint time;
+ internal int ptX;
+ internal int ptY;
+ }
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool PeekMessage(
+ out MSG lpMsg,
+ IntPtr hWnd,
+ uint wMsgFilterMin,
+ uint wMsgFilterMax,
+ uint wRemoveMsg);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool TranslateMessage(ref MSG lpMsg);
+
+ [DllImport("user32.dll")]
+ internal static extern IntPtr DispatchMessage(ref MSG lpmsg);
+ }
+}