diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs
new file mode 100644
index 00000000..30d39394
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs
@@ -0,0 +1,206 @@
+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();
+ }
+ }
+
+ public void Up() => Listen(Port);
+
+ 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