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 +