Auto: s7-c1 — surface negotiated PDU size via DriverHealth.Diagnostics

Closes #294
This commit is contained in:
Joseph Doherty
2026-04-26 00:35:49 -04:00
parent f469cf7e0d
commit 6540bbe1ef
6 changed files with 165 additions and 1 deletions

View File

@@ -67,6 +67,18 @@ DB1 (1024 bytes) + MB (256 bytes) with typed seeds at known offsets:
Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`,
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING).
## Negotiated PDU size
Snap7's `Server` always negotiates a **fixed 240-byte PDU** during the
COTP/S7comm handshake (the legacy default — Snap7 does not implement the
S7-1500 extended-PDU negotiation). The S7 driver surfaces this value
through `DriverHealth.Diagnostics["S7.NegotiatedPduSize"]`, exercised by
`S7_1500DiagnosticsTests.Driver_exposes_negotiated_pdu_size_post_init`.
Operators inspecting a driver against this fixture should therefore see
`S7.NegotiatedPduSize = 240`. Real S7-1500 CPUs running the extended PDU
report 480 or 960; that branch is hardware-only and not exercised here.
## Known limitations
From the `snap7.server.Server` docstring upstream:

View File

@@ -0,0 +1,42 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// End-to-end coverage for the S7 driver's diagnostics surface against the python-snap7
/// S7-1500 simulator. The simulator runs the upstream Snap7 server which negotiates a
/// fixed 240-byte PDU during the COTP handshake; this suite asserts the driver captures
/// and surfaces that value through <c>DriverHealth.Diagnostics["S7.NegotiatedPduSize"]</c>
/// so the Admin UI driver-diagnostics panel can render it.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500DiagnosticsTests(Snap7ServerFixture sim)
{
[Fact]
public async Task Driver_exposes_negotiated_pdu_size_post_init()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
await using var drv = new S7Driver(options, driverInstanceId: "s7-pdu-diag");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// Issue a read so BuildDiagnostics() flows into _health.Diagnostics through the
// Healthy state-transition (the post-OpenAsync construction also seeds it, but we
// exercise the read path too so the assertion is robust against future refactors
// that move the seed point).
var snapshots = await drv.ReadAsync(
[S7_1500Profile.ProbeTag], TestContext.Current.CancellationToken);
snapshots[0].StatusCode.ShouldBe(0u, "probe read must succeed before checking diagnostics");
var diagnostics = drv.GetHealth().DiagnosticsOrEmpty;
diagnostics.ContainsKey("S7.NegotiatedPduSize").ShouldBeTrue(
"S7 driver must surface negotiated PDU size as S7.NegotiatedPduSize");
diagnostics["S7.NegotiatedPduSize"].ShouldBeGreaterThan(0,
"Snap7 negotiates a 240-byte PDU on every COTP handshake — anything ≤ 0 means the snapshot was missed");
}
}

View File

@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Unit-level coverage for the <c>DriverHealth.Diagnostics</c> dictionary the S7 driver
/// exposes through <see cref="S7Driver.GetHealth"/>. Wire-level reads of
/// <c>S7.NegotiatedPduSize</c> against a real handshake live in the Snap7 integration
/// suite (where the simulator negotiates a fixed 240-byte PDU); this suite covers the
/// before-connect / after-shutdown contract on the same key without booting a fixture.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverDiagnosticsTests
{
[Fact]
public void NegotiatedPduSize_diagnostics_key_starts_at_zero_before_initialize()
{
// Before InitializeAsync the driver hasn't talked to a PLC, so the negotiated PDU
// is unknown. We surface 0 (rather than omitting the key) so the Admin UI driver-
// diagnostics panel can render a stable row even pre-connect — operators see "0"
// and immediately know the driver hasn't completed a handshake yet.
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-pre-init");
var health = drv.GetHealth();
// Pre-init the driver's state is Unknown and Diagnostics is null (the empty dict);
// the field-backed counter is what we directly assert here.
drv.NegotiatedPduSize.ShouldBe(0);
// The DriverHealth surface should also be safe to consume.
health.DiagnosticsOrEmpty.ContainsKey("S7.NegotiatedPduSize").ShouldBeFalse(
"pre-init Diagnostics is null and DiagnosticsOrEmpty returns the empty dict");
}
[Fact]
public async Task NegotiatedPduSize_resets_to_zero_after_shutdown()
{
// Even when InitializeAsync fails (unreachable host), the field must remain at 0
// so a subsequent ShutdownAsync doesn't carry a stale value forward into the next
// re-init attempt. RFC 5737 documentation IP guarantees a fast TCP timeout.
var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
using var drv = new S7Driver(opts, "s7-shutdown");
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
// The Faulted path in InitializeAsync's catch block doesn't touch _negotiatedPduSize
// (we never reached the post-OpenAsync capture) so it should still be 0.
drv.NegotiatedPduSize.ShouldBe(0);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(0, "ShutdownAsync must zero the negotiated PDU snapshot");
}
}