diff --git a/docs/AlarmClientDiscovery.md b/docs/AlarmClientDiscovery.md
index 74b87e5..39e4484 100644
--- a/docs/AlarmClientDiscovery.md
+++ b/docs/AlarmClientDiscovery.md
@@ -258,16 +258,82 @@ the script's writes. So the alarm extension is **evaluating**
its condition, just not visibly producing transitions on the
`aaAlarmManagedClient` consumer stream.
-This isolates the unknown to the producer-side path — whether
-the BoolAlarm extension's "publish to alarm manager" knob is on,
-whether the platform is in an alarm area that matches the
-consumer's subscription scope, or whether AVEVA has a separate
-"events" path the BoolAlarm uses by default that this consumer
-doesn't subscribe to. Resolving requires checking the BoolAlarm
-extension's config in System Platform IDE (alarm priority,
-category, "Active"/"Enabled" flags, alarm-vs-event mode) and
-checking whether `aaObjectViewer`'s Active Alarms panel sees the
-alarm fire.
+## Multi-channel + multi-subscription probe — sixth run, 2026-05-01
+
+Extended the probe to try every consumer-side approach in
+parallel:
+
+- **Subscription expressions** (sequential): `\Galaxy!`,
+ `\Galaxy!*`, `\\Galaxy!`, `\Galaxy!TestArea`, `\\.\Galaxy!`.
+ All Subscribe calls returned rc=0; the last one
+ (`\\.\Galaxy!`) is reflected in `GetProviders` (count=1).
+- **Read channels** polled at 500ms cadence: `GetStatistics`,
+ `GetHighPriAlarm`, `SFCreateSnapshot` + `SFGetStatistics`.
+- **Filter+sort**: priority 0..32767, `qtSummary`,
+ state=`asAlarmActiveNow`, sort=`sfReturnNewestFirst`.
+- **AlarmRecord init** (worked around `Not a valid Win32
+ FileTime` exception): all DateTime fields pre-set to FILETIME
+ epoch (1601-01-01 UTC) before the call, since
+ `default(DateTime)` is outside FILETIME range and trips the
+ interop marshaler.
+
+Result of the 60s run with `TestMachine_001.TestAlarm001` being
+flipped every 10s:
+
+```
+Subscribe('\Galaxy!') -> 0
+Subscribe('\Galaxy!*') -> 0
+Subscribe('\\Galaxy!') -> 0
+Subscribe('\Galaxy!TestArea') -> 0
+Subscribe('\\.\Galaxy!') -> 0
+GetProviders [after Subscribe-multi] -> count=1 list=[ 0 \\.\Galaxy!]
+GetStatistics #1: total=0 active=0 changes=1 codes=[7] positions=[] handles=[]
+GetHighPriAlarm #1: rc=0 { }
+SF channel #1: SFCreate=0 numAlarms=0 SFStats=0 unackRet=0 unackAlm=0 ackAlm=0 others=0 events=0 idxNewest=-1
+```
+
+**No further "(changed)" entries for the entire 60s window.**
+Every read API returned the same empty result on every poll.
+
+User confirms the alarm IS firing — `aaObjectViewer` sees
+`$Alarm.InAlarm` flip in lockstep with the script. Historian
+records exist (per user — needs verification by querying the
+historian directly).
+
+## Conclusion of consumer-side probing
+
+`aaAlarmManagedClient.AlarmClient` is **not** the receive
+surface AVEVA's alarm pipeline routes to in this Galaxy
+configuration. The consumer chain is verified end-to-end:
+
+- `InitializeConsumer` + `RegisterConsumer` + `Subscribe` all
+ succeed (rc=0).
+- `GetProviders` finds `\Galaxy!` once Initialize is called.
+- All read APIs (`GetStatistics`, `GetHighPriAlarm`,
+ `SFCreateSnapshot`/`SFGetStatistics`) return empty even with
+ every documented filter combination.
+- The consumer's hWnd receives zero AVEVA messages between
+ `WM_CREATE` and `WM_DESTROY`; AVEVA's traffic goes to its own
+ internal hwnd.
+
+The next investigation directions are not consumer-side:
+
+1. **Inspect `aaObjectViewer`'s alarm SDK** to see what library
+ it uses to read alarms. If different from
+ `aaAlarmManagedClient`, switch the worker over.
+2. **Query the historian directly** (`aahEventStorage` /
+ `aahEventSvc`) to confirm alarms are recorded — and use the
+ same path for v2 alarm capture.
+3. **Inspect AVEVA's alarm-routing config** for this Galaxy in
+ System Platform IDE — area assignments, alarm provider
+ bindings, "publish alarm events to" settings on the platform.
+
+For A.2 implementation: the `aaAlarmManagedClient` path the
+gateway-worker is currently architected around may be a
+dead-end on customer Galaxies configured this way. If the
+alarms truly only flow through the historian event-storage path,
+A.2 needs to consume from `aahEventStorage` instead — a
+fundamental architecture pivot.
### Implications for A.2 implementation
diff --git a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs
index d139583..44ac80e 100644
--- a/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs
+++ b/src/MxGateway.Worker.Tests/AlarmClientWmProbeTests.cs
@@ -32,6 +32,17 @@ namespace MxGateway.Worker.Tests;
public sealed class AlarmClientWmProbeTests : IDisposable
{
// Probe configuration. Override in the constructor below if needed.
+ // Try multiple subscription expressions sequentially (each Subscribe call
+ // adds to the consumer's scope). The "everything" form varies by AVEVA
+ // version — we shotgun common forms.
+ private static readonly string[] SubscriptionExpressions =
+ {
+ @"\Galaxy!", // documented "all groups under Galaxy provider"
+ @"\Galaxy!*", // wildcard variant
+ @"\\Galaxy!", // double-backslash UNC-style
+ @"\Galaxy!TestArea", // explicit area where TestMachine_001 lives
+ @"\\.\Galaxy!", // local-host prefix
+ };
private const string SubscriptionExpression = @"\Galaxy!";
private static readonly TimeSpan PumpDuration = TimeSpan.FromSeconds(60);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
@@ -281,16 +292,28 @@ public sealed class AlarmClientWmProbeTests : IDisposable
// literally mean "match alarms in state 'none'" (i.e., nothing),
// since the eAlarmFilterState enum is 0/1/2/3 single-states not
// flag bits. Try ActiveNow explicitly.
- int subscribe = client.Subscribe(
- szSubscription: SubscriptionExpression,
- wFromPri: 0, wToPri: short.MaxValue,
- QueryType: eQueryType.qtHistory,
- SortFlags: eSortFlags.sfReturnNewestFirst,
- FilterMask: eAlarmFilterState.asAlarmActiveNow,
- FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
- Log($"Subscribe('{SubscriptionExpression}', qtHistory, state=ActiveNow, pri=[0..32767]) -> {subscribe}");
+ // Subscribe to every candidate expression — AVEVA accepts multiple
+ // overlapping subscriptions; whichever matches the producer wins.
+ foreach (string expr in SubscriptionExpressions)
+ {
+ try
+ {
+ int subscribe = client.Subscribe(
+ szSubscription: expr,
+ wFromPri: 0, wToPri: short.MaxValue,
+ QueryType: eQueryType.qtSummary,
+ SortFlags: eSortFlags.sfReturnNewestFirst,
+ FilterMask: eAlarmFilterState.asAlarmActiveNow,
+ FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
+ Log($"Subscribe('{expr}') -> {subscribe}");
+ }
+ catch (Exception ex)
+ {
+ Log($"Subscribe('{expr}') threw: {ex.GetType().Name}: {ex.Message}");
+ }
+ }
- LogProviders(client, "after Subscribe");
+ LogProviders(client, "after Subscribe-multi");
// 3c. Pump for the configured duration. Log every message we see
// (filtered light to avoid noise from WM_PAINT / WM_TIMER /
@@ -322,6 +345,7 @@ public sealed class AlarmClientWmProbeTests : IDisposable
{
PollGetStatistics(client, ++pollCount);
LogProviders(client, $"poll #{pollCount}");
+ PollAllChannels(client, pollCount);
nextPoll = DateTime.UtcNow + PollInterval;
}
Thread.Sleep(10);
@@ -349,6 +373,122 @@ public sealed class AlarmClientWmProbeTests : IDisposable
private string lastStatsSummary = string.Empty;
private string lastProvidersSummary = string.Empty;
+ private string lastHighPriSummary = string.Empty;
+ private string lastSfStatsSummary = string.Empty;
+
+ ///
+ /// Try every read API the AlarmClient exposes and log when its
+ /// output changes. AlarmClient has at least three distinct read
+ /// surfaces — GetStatistics (current-change array), GetHighPriAlarm
+ /// (single-record peek), and the SF (stored filter) family — and any
+ /// of them might be the populated one.
+ ///
+ private static AlarmRecord NewAlarmRecord()
+ {
+ // The interop's auto-marshal flips DateTime fields to FILETIME on
+ // the way IN as well as OUT. default(DateTime) (year 1) is outside
+ // FILETIME's representable range, so initialize all DateTime fields
+ // to the FILETIME epoch (1601-01-01 UTC) to satisfy the marshaler.
+ AlarmRecord rec = new AlarmRecord();
+ DateTime epoch = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ foreach (var f in typeof(AlarmRecord).GetFields(
+ BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic))
+ {
+ if (f.FieldType == typeof(DateTime))
+ {
+ object boxed = rec;
+ f.SetValue(boxed, epoch);
+ rec = (AlarmRecord)boxed;
+ }
+ }
+ return rec;
+ }
+
+ private void PollAllChannels(AlarmClient client, int seq)
+ {
+ // Channel A: GetHighPriAlarm — direct peek of highest-priority alarm.
+ try
+ {
+ AlarmRecord rec = NewAlarmRecord();
+ int rc = client.GetHighPriAlarm(ref rec);
+ string desc = rc == 0 ? DescribeAlarmRecord(rec) : "";
+ string summary = $"rc={rc} {desc}";
+ if (summary != lastHighPriSummary)
+ {
+ Log($"GetHighPriAlarm #{seq}: {summary} (changed)");
+ lastHighPriSummary = summary;
+ }
+ }
+ catch (Exception ex)
+ {
+ string es = $"{ex.GetType().Name}: {ex.Message}";
+ if (es != lastHighPriSummary)
+ {
+ Log($"GetHighPriAlarm #{seq}: threw {es}");
+ lastHighPriSummary = es;
+ }
+ }
+
+ // Channel C: GetAlarmExtendedRec by index. Try indices 0..3 directly;
+ // populated alarms (if any) appear at low indices.
+ for (int idx = 0; idx <= 2; idx++)
+ {
+ try
+ {
+ AlarmRecord rec = NewAlarmRecord();
+ int rc = client.GetAlarmExtendedRec(idx, ref rec);
+ if (rc == 0)
+ {
+ string desc = DescribeAlarmRecord(rec);
+ Log($"GetAlarmExtendedRec(idx={idx}) #{seq}: rc=0 -> {desc}");
+ break; // log first present record only
+ }
+ }
+ catch (Exception ex)
+ {
+ if (idx == 0)
+ {
+ Log($"GetAlarmExtendedRec(idx=0) #{seq}: threw {ex.GetType().Name}: {ex.Message}");
+ }
+ break;
+ }
+ }
+
+ // Channel B: SF — snapshot + GetStatistics + iterate.
+ try
+ {
+ uint numAlarms = 0;
+ int sfCreate = client.SFCreateSnapshot(0, ref numAlarms);
+ int unackRet = 0, unackAlm = 0, ackAlm = 0, others = 0, events = 0, idxNewest = 0;
+ int sfStats = client.SFGetStatistics(
+ ref unackRet, ref unackAlm, ref ackAlm,
+ ref others, ref events, ref idxNewest);
+ string summary = $"SFCreate={sfCreate} numAlarms={numAlarms} " +
+ $"SFStats={sfStats} unackRet={unackRet} unackAlm={unackAlm} " +
+ $"ackAlm={ackAlm} others={others} events={events} idxNewest={idxNewest}";
+ if (summary != lastSfStatsSummary)
+ {
+ Log($"SF channel #{seq}: {summary} (changed)");
+ lastSfStatsSummary = summary;
+
+ // If non-zero, fetch the first record by index via the
+ // standard GetAlarmExtendedRec — after SFCreateSnapshot the
+ // indices reference the snapshot.
+ if (numAlarms > 0)
+ {
+ AlarmRecord rec = new AlarmRecord();
+ int recRc = client.GetAlarmExtendedRec(0, ref rec);
+ Log($" GetAlarmExtendedRec(0) [post-snapshot] rc={recRc} -> {DescribeAlarmRecord(rec)}");
+ }
+ }
+ client.SFDeleteSnapshot();
+ }
+ catch (Exception ex)
+ {
+ Log($"SF channel #{seq}: threw {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
private void LogProviders(AlarmClient client, string when)
{