using System.Net; using System.Net.Sockets; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series; /// /// #740 fix round 1 (review I2) — wire-level cases for H15 (fixed-tree health) and H20 (alarm record layout) /// against the focas-mock fixture. /// [Collection(FocasSimCollection.Name)] public sealed class WireBackendHealthAndLayoutTests { private readonly FocasSimFixture _fx; /// Initializes a new instance of the test class with the FOCAS simulation fixture. /// The FOCAS simulation fixture. public WireBackendHealthAndLayoutTests(FocasSimFixture fx) => _fx = fx; /// /// A local TCP relay in front of the shared mock, so ONE test can take "the CNC" away and bring it back /// without stopping a fixture other suites use: closes every relayed session and stops /// listening (new connects are refused); listens again on the same port. /// private sealed class CncRelay : IAsyncDisposable { private readonly string _targetHost; private readonly int _targetPort; private readonly List _live = []; private TcpListener? _listener; private CancellationTokenSource? _cts; public CncRelay(string targetHost, int targetPort) { _targetHost = targetHost; _targetPort = targetPort; Port = Listen(0); } public int Port { get; private set; } private int Listen(int port) { _cts = new CancellationTokenSource(); _listener = new TcpListener(IPAddress.Loopback, port); _listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listener.Start(); var ct = _cts.Token; var listener = _listener; _ = Task.Run(() => AcceptLoopAsync(listener, ct), CancellationToken.None); return ((IPEndPoint)_listener.LocalEndpoint).Port; } private async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct) { while (!ct.IsCancellationRequested) { TcpClient inbound; try { inbound = await listener.AcceptTcpClientAsync(ct); } catch { return; } var outbound = new TcpClient(); try { await outbound.ConnectAsync(_targetHost, _targetPort, ct); } catch { inbound.Dispose(); outbound.Dispose(); continue; } lock (_live) { _live.Add(inbound); _live.Add(outbound); } _ = Pump(inbound, outbound, ct); _ = Pump(outbound, inbound, ct); } } private static async Task Pump(TcpClient from, TcpClient to, CancellationToken ct) { try { await from.GetStream().CopyToAsync(to.GetStream(), ct); } catch { /* the relay was taken down, or a side closed */ } finally { from.Dispose(); to.Dispose(); } } public void Down() { _cts?.Cancel(); _listener?.Stop(); lock (_live) { foreach (var c in _live) c.Dispose(); _live.Clear(); } } /// /// Listens again on the SAME port (the driver's host string names it). The re-bind is retried briefly: the /// OS can hold the port for a moment after , or hand it to another process — the latter /// surfaces as a clear failure after the retries rather than a random flake. /// public void Up() { for (var attempt = 1; ; attempt++) { try { Listen(Port); return; } catch (SocketException) when (attempt < 20) { Thread.Sleep(100); } } } public ValueTask DisposeAsync() { Down(); return ValueTask.CompletedTask; } } /// /// H15 over the wire: a driver serving only FIXED: tags is Healthy with a moving LastSuccessfulRead /// while its CNC answers; when the CNC disappears it degrades with LastSuccessfulRead frozen (the #527 /// watchdog's conjunction); and it recovers to Healthy when the CNC returns (positive control). /// [Fact] public async Task Fixed_only_driver_degrades_on_cnc_loss_and_recovers_over_the_wire() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); await using var relay = new CncRelay(_fx.Host, _fx.Port); var host = $"focas://127.0.0.1:{relay.Port}"; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(host)], RawTags = FocasRawTags.Entries( new FocasTagDefinition("Series", host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false)), Probe = new FocasProbeOptions { Enabled = false }, Timeout = TimeSpan.FromSeconds(1), FixedTree = new FocasFixedTreeOptions { PollInterval = TimeSpan.FromMilliseconds(50), ProgramPollInterval = TimeSpan.Zero, TimerPollInterval = TimeSpan.Zero, }, }, driverInstanceId: "wire-fixed-health", clientFactory: new WireFocasClientFactory()) { FixedTreeStaleBudgetOverride = TimeSpan.FromMilliseconds(500), }; await using (drv) { await drv.InitializeAsync("{}", ct); await WaitFor(() => drv.GetDeviceState(host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(10)); var first = drv.GetHealth().LastSuccessfulRead!.Value; await WaitFor(() => drv.GetHealth().LastSuccessfulRead > first, TimeSpan.FromSeconds(5)); drv.GetHealth().State.ShouldBe(DriverState.Healthy); relay.Down(); await WaitFor(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(15)); var frozen = drv.GetHealth().LastSuccessfulRead; await Task.Delay(TimeSpan.FromSeconds(1), ct); drv.GetHealth().State.ShouldBe(DriverState.Degraded); drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "nothing answered, so LastSuccessfulRead stays frozen"); (await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError, "the stale fixed-tree value reads Bad on the same budget that degrades the driver"); // Positive control: the CNC returns ⇒ Healthy again, LastSuccessfulRead moves. The connect backoff // (1 s doubling) bounds how soon the loop re-dials. relay.Up(); await WaitFor(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(40)); drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value); (await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good); } } /// /// H20 over the wire: the mock answers cnc_rdalmmsg2 in the 80-byte LEGACY layout. Nineteen active /// alarms make a 1,520-byte payload — also exactly 20 × 76 — which the old length test decoded as twenty /// garbage vendor records. Positive control: the unambiguous 3-alarm reply decodes the same alarms. /// [Fact] public async Task Nineteen_legacy_alarms_decode_as_nineteen_over_the_wire() { if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason); var ct = TestContext.Current.CancellationToken; await _fx.LoadProfileAsync("FWLIB64", ct); var client = new WireFocasClientFactory().Create(); try { await client.ConnectAsync(FocasHostAddress.TryParse(_fx.DeviceUri)!, TimeSpan.FromSeconds(2), ct); await _fx.PatchStateAsync(new { alarms = Alarms(3) }, ct); var three = await client.ReadAlarmsAsync(ct); three.Count.ShouldBe(3); three[2].AlarmNumber.ShouldBe(402); await _fx.PatchStateAsync(new { alarms = Alarms(19) }, ct); var nineteen = await client.ReadAlarmsAsync(ct); nineteen.Count.ShouldBe(19, "a 1,520-byte legacy reply is 19 records, not 20 vendor ones"); nineteen.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(400, 19)); nineteen[18].Message.ShouldBe("SERVO ALARM 418"); nineteen[18].Axis.ShouldBe((short)(1 + (18 % 4))); } finally { client.Dispose(); await _fx.PatchStateAsync(new { alarms = Array.Empty() }, ct); } static object[] Alarms(int n) => [.. Enumerable.Range(0, n) .Select(i => (object)new { alm_no = 400 + i, type = 2 + (i % 3), axis = 1 + (i % 4), msg = $"SERVO ALARM {400 + i}" })]; } private static async Task WaitFor(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!condition()) { if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached"); await Task.Delay(25); } } }