diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs
index de38f37..69cae55 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccess/MxAccessClient.cs
@@ -54,6 +54,13 @@ public sealed class MxAccessClient : IDisposable
public int SubscriptionCount => _subscriptions.Count;
public int ReconnectCount => _reconnectCount;
+ ///
+ /// Wonderware client identity used when registering with the LMXProxyServer. Surfaced so
+ /// can tag its OnHostStatusChanged IPC
+ /// pushes with a stable gateway name per PR 8.
+ ///
+ public string ClientName => _clientName;
+
/// Connects on the STA thread. Idempotent. Starts the reconnect monitor on first call.
public async Task ConnectAsync()
{
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs
index 7cd543a..a13307d 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs
@@ -34,16 +34,34 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
_refToSubs = new(System.StringComparer.OrdinalIgnoreCase);
public event System.EventHandler? OnDataChange;
-#pragma warning disable CS0067 // event not yet raised — alarm + host-status wire-up in PR #4 follow-up
+#pragma warning disable CS0067 // alarm wire-up deferred to PR 9
public event System.EventHandler? OnAlarmEvent;
- public event System.EventHandler? OnHostStatusChanged;
#pragma warning restore CS0067
+ public event System.EventHandler? OnHostStatusChanged;
+
+ private readonly System.EventHandler _onConnectionStateChanged;
public MxAccessGalaxyBackend(GalaxyRepository repository, MxAccessClient mx, IHistorianDataSource? historian = null)
{
_repository = repository;
_mx = mx;
_historian = historian;
+
+ // PR 8: gateway-level host-status push. When the MXAccess COM proxy transitions
+ // connected↔disconnected, raise OnHostStatusChanged with a synthetic host entry named
+ // after the Wonderware client identity so the Admin UI surfaces top-level transport
+ // health even before per-platform/per-engine probing lands (deferred to a later PR that
+ // ports v1's GalaxyRuntimeProbeManager with ScanState subscriptions).
+ _onConnectionStateChanged = (_, connected) =>
+ {
+ OnHostStatusChanged?.Invoke(this, new HostConnectivityStatus
+ {
+ HostName = _mx.ClientName,
+ RuntimeStatus = connected ? "Running" : "Stopped",
+ LastObservedUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ });
+ };
+ _mx.ConnectionStateChanged += _onConnectionStateChanged;
}
public async Task OpenSessionAsync(OpenSessionRequest req, CancellationToken ct)
@@ -267,7 +285,11 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable
public Task RecycleAsync(RecycleHostRequest req, CancellationToken ct)
=> Task.FromResult(new RecycleStatusResponse { Accepted = true, GraceSeconds = 15 });
- public void Dispose() => _historian?.Dispose();
+ public void Dispose()
+ {
+ _mx.ConnectionStateChanged -= _onConnectionStateChanged;
+ _historian?.Dispose();
+ }
private static GalaxyDataValue ToWire(string reference, Vtq vtq) => new()
{
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs
new file mode 100644
index 0000000..f32f627
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HostStatusPushTests.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+using ArchestrA.MxAccess;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class HostStatusPushTests
+{
+ ///
+ /// PR 8 — when MxAccessClient.ConnectionStateChanged fires false→true→false,
+ /// MxAccessGalaxyBackend raises OnHostStatusChanged once per transition with
+ /// HostName=ClientName, RuntimeStatus="Running"/"Stopped", and a timestamp.
+ /// This is the gateway-level signal; per-platform ScanState probes are deferred.
+ ///
+ [Fact]
+ public async Task ConnectionStateChanged_raises_OnHostStatusChanged_with_gateway_name()
+ {
+ using var pump = new StaPump("Test.Sta");
+ await pump.WaitForStartedAsync();
+ var proxy = new FakeProxy();
+ var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false });
+ using var backend = new MxAccessGalaxyBackend(
+ new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
+ mx,
+ historian: null);
+
+ var notifications = new ConcurrentQueue();
+ backend.OnHostStatusChanged += (_, s) => notifications.Enqueue(s);
+
+ await mx.ConnectAsync();
+ await mx.DisconnectAsync();
+
+ notifications.Count.ShouldBe(2);
+ notifications.TryDequeue(out var first).ShouldBeTrue();
+ first!.HostName.ShouldBe("GatewayClient");
+ first.RuntimeStatus.ShouldBe("Running");
+ first.LastObservedUtcUnixMs.ShouldBeGreaterThan(0);
+
+ notifications.TryDequeue(out var second).ShouldBeTrue();
+ second!.HostName.ShouldBe("GatewayClient");
+ second.RuntimeStatus.ShouldBe("Stopped");
+ }
+
+ [Fact]
+ public async Task Dispose_unsubscribes_so_post_dispose_state_changes_do_not_fire_events()
+ {
+ using var pump = new StaPump("Test.Sta");
+ await pump.WaitForStartedAsync();
+ var proxy = new FakeProxy();
+ var mx = new MxAccessClient(pump, proxy, "GatewayClient", new MxAccessClientOptions { AutoReconnect = false });
+ var backend = new MxAccessGalaxyBackend(
+ new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }),
+ mx,
+ historian: null);
+
+ var count = 0;
+ backend.OnHostStatusChanged += (_, _) => Interlocked.Increment(ref count);
+
+ await mx.ConnectAsync();
+ count.ShouldBe(1);
+
+ backend.Dispose();
+ await mx.DisconnectAsync();
+
+ count.ShouldBe(1); // no second notification after Dispose
+ }
+
+ private sealed class FakeProxy : IMxProxy
+ {
+ private int _next = 1;
+ public int Register(string _) => 42;
+ public void Unregister(int _) { }
+ public int AddItem(int _, string __) => Interlocked.Increment(ref _next);
+ public void RemoveItem(int _, int __) { }
+ public void AdviseSupervisory(int _, int __) { }
+ public void UnAdviseSupervisory(int _, int __) { }
+ public void Write(int _, int __, object ___, int ____) { }
+ public event MxDataChangeHandler? OnDataChange { add { } remove { } }
+ public event MxWriteCompleteHandler? OnWriteComplete { add { } remove { } }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj
index fd5d722..6f803d5 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj
@@ -24,6 +24,11 @@
+
+
+ ..\..\lib\ArchestrA.MxAccess.dll
+