Merge lane S7: S7 driver remediation (#742)

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:11:24 -04:00
36 changed files with 2933 additions and 582 deletions
@@ -142,7 +142,7 @@ public sealed class CsvMergeEvidenceTests
var row = SingleRow(
DriverTypeNames.S7,
"Name,DataType,Address,S7DataType,TagConfigJson",
"t1,Int16,DB1.DBW0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
"t1,Int16,DB1.DBD0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
var blob = Blob(row);
blob["dataType"]!.GetValue<string>().ShouldBe("Int32");
@@ -346,7 +346,11 @@ public sealed class RawTagConfigGateTests
public void A_browse_committed_config_passes_the_gate(string driverType)
{
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType));
driverType,
// Drivers whose address grammar is validated by their reader need a real address: AbLegacy's
// file grammar, and S7's (S7TagShapeRules — an unparseable or wrong-width address is refused).
driverType == DriverTypeNames.AbLegacy ? "N7:0" : driverType == DriverTypeNames.S7 ? "DB1.DBW0" : "SomeAddress",
BrowseFieldsFor(driverType));
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
$"browse-commit for {driverType} must not author a config the import refuses");
@@ -98,13 +98,13 @@ public sealed class S7TagConfigModelTests
/// driver serves. (An absent key is deliberately not asserted: S7 alone hard-requires the key, an
/// asymmetry that predates this change and that its own <c>Inspect</c> does not report either.)</summary>
[Theory]
[InlineData("""{"address":"DB1.DBW0","dataType":"float32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"FLOAT32"}""")]
[InlineData("""{"address":"DB1.DBD0","dataType":"float32"}""")]
[InlineData("""{"address":"DB1.DBD0","dataType":"FLOAT32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":7}""")]
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
=> S7TagConfigModel.FromJson(json).Validate().ShouldBeNull();
// ---- S7-016: array shape vs the driver's init guards (which fail the WHOLE driver init) ----
// ---- S7-016: array shape vs S7TagShapeRules (refused per tag at every gate) ----
[Theory]
[InlineData("Int64")]
@@ -160,9 +160,9 @@ public sealed class TagConfigNonEnumParityTests
/// <summary>S7's remaining non-enum refusal: a <c>String</c> tag whose <c>stringLength</c> falls outside
/// 0–254. The reader REJECTS it (it does not clamp), so the tag would never resolve.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":-1}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":-1}""")]
public void S7_refuses_a_string_length_its_reader_refuses(string blob)
{
RuntimeAccepts(DriverTypeNames.S7, blob).ShouldBeFalse(
@@ -174,9 +174,9 @@ public sealed class TagConfigNonEnumParityTests
/// stops this becoming an over-rejection — an out-of-range <c>stringLength</c> on a tag that is not a
/// String at all, which the reader ignores entirely.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"Int16","stringLength":300}""")]
public void S7_accepts_a_string_length_its_reader_accepts(string blob)
{
@@ -360,8 +360,8 @@ public sealed class TagConfigNonEnumParityTests
(DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBW0"}"""),
(DriverTypeNames.S7, """{"address":" ","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.AbCip, "{}"),
(DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed"}"""),
@@ -576,7 +576,7 @@ public sealed class TagConfigNonEnumParityTests
data.Add(DriverTypeNames.Modbus, new ModbusTagConfigModel { Address = 1 }.ToJson());
data.Add(DriverTypeNames.S7, new S7TagConfigModel { Address = "DB1.DBW0" }.ToJson());
data.Add(DriverTypeNames.S7,
new S7TagConfigModel { Address = "DB1.DBS0", DataType = S7DataType.String, StringLength = 24 }.ToJson());
new S7TagConfigModel { Address = "DB1.DBB0", DataType = S7DataType.String, StringLength = 24 }.ToJson());
data.Add(DriverTypeNames.AbCip, new AbCipTagConfigModel { TagPath = "Program:Main.Speed" }.ToJson());
data.Add(DriverTypeNames.AbLegacy, new AbLegacyTagConfigModel { Address = "N7:0" }.ToJson());
data.Add(DriverTypeNames.TwinCAT, new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" }.ToJson());
@@ -0,0 +1,123 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using S7DataItem = global::S7.Net.Types.DataItem;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Lane S7 review I-1, end to end: the REAL <see cref="S7Driver"/> under the REAL
/// <see cref="DriverInstanceActor"/> comms-loss watchdog. A CPU with PUT/GET disabled denies every
/// read but is reachable and answering; the driver reports <c>Faulted</c> (the config alert) and must
/// still advance <c>LastSuccessfulRead</c>, so the watchdog's "non-Healthy AND silent for the
/// threshold" conjunction is never met and the actor does NOT reconnect + re-subscribe. The
/// positive control is the same rig with a real comms loss, which DOES trip it.
/// </summary>
public sealed class S7AccessDeniedWatchdogTests : RuntimeActorTestBase
{
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50);
/// <summary>The watchdog threshold — an order of magnitude above the poll so the window is a decision, not a race.</summary>
private static readonly TimeSpan Threshold = TimeSpan.FromMilliseconds(1500);
private sealed class ScriptedPlcFactory(Func<Exception> readFault) : IS7PlcFactory
{
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
=> new ScriptedPlc(readFault);
}
private sealed class ScriptedPlc(Func<Exception> readFault) : IS7Plc
{
public bool IsConnected { get; private set; }
public Task OpenAsync(CancellationToken cancellationToken) { IsConnected = true; return Task.CompletedTask; }
public void Close() => IsConnected = false;
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken) => Task.FromException<object?>(readFault());
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
=> Task.FromException<byte[]>(readFault());
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(List<S7DataItem> items, CancellationToken cancellationToken)
=> Task.FromException<IReadOnlyList<S7DataItem>>(readFault());
public Task WriteAsync(string address, object value, CancellationToken cancellationToken) => Task.CompletedTask;
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) => Task.CompletedTask;
public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() => IsConnected = false;
}
private (S7Driver Driver, Akka.TestKit.TestProbe Parent) Connect(Func<Exception> readFault)
{
var driver = new S7Driver(new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
SpanReadMaxGapBytes = -1,
RawTags =
[
new RawTagEntry("W0", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), false),
new RawTagEntry("W2", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W2", "DB1.DBW2", S7DataType.UInt16, Writable: false)), false),
],
}, "s7-watchdog", new ScriptedPlcFactory(readFault));
var parent = CreateTestProbe();
var actor = parent.ChildActorOf(DriverInstanceActor.Props(
driver,
reconnectInterval: TimeSpan.FromSeconds(30),
healthPollInterval: FastPoll,
commsLossTimeout: Threshold));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(TimeSpan.FromSeconds(5)).Connected.ShouldBeTrue();
return (driver, parent);
}
/// <summary>Drives read passes (the poll the actor would schedule) until <paramref name="until"/>.</summary>
private static async Task<(int Passes, int FaultedPasses)> PollUntilAsync(S7Driver driver, DateTime until)
{
var passes = 0;
var faulted = 0;
while (DateTime.UtcNow < until)
{
try
{
#pragma warning disable OTOPCUA0001 // the watchdog's reading of driver health is under test, not resilience wrapping
await driver.ReadAsync(["W0", "W2"], CancellationToken.None);
#pragma warning restore OTOPCUA0001
passes++;
if (driver.GetHealth().State == DriverState.Faulted) faulted++;
}
catch (InvalidOperationException) { /* the actor tore the driver down (control case) */ }
await Task.Delay(100); // pacing the simulated poll, not an assertion
}
return (passes, faulted);
}
/// <summary>An all-access-denied CPU is left connected across more than twice the watchdog threshold.</summary>
[Fact]
public async Task An_access_denied_cpu_is_not_reconnected_by_the_watchdog()
{
var (driver, parent) = Connect(() => new Exception("Received error from PLC: Accessing object not allowed."));
var (passes, faulted) = await PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500));
// Positive controls: the window really ran past the threshold with the driver Faulted throughout,
// so "no reconnect" below cannot pass merely because nothing happened.
passes.ShouldBeGreaterThanOrEqualTo(10);
faulted.ShouldBe(passes, "every pass reports the PUT/GET config alert");
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>The control: a real comms loss (every read times out) on the same rig IS reconnected.</summary>
[Fact]
public async Task A_real_comms_loss_is_reconnected_by_the_watchdog()
{
var (driver, parent) = Connect(() => new TimeoutException("frozen peer"));
var poll = PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500));
parent.ExpectMsg<DriverInstanceActor.ConnectivityChanged>(PresenceBudget).Connected.ShouldBeFalse();
await poll;
}
}
@@ -39,6 +39,9 @@
re-statement end-to-end tests prove what reaches the host's /alerts rows and Part 9 conditions. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<!-- Lane S7 review I-1: the real S7 driver (over a scripted IS7Plc), so the comms-loss watchdog test proves an
access-denied (PUT/GET disabled) CPU is NOT reconnected every 90 s, against the real actor. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
</ItemGroup>
<ItemGroup>