bbea38483f
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
264 lines
12 KiB
C#
264 lines
12 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
|
|
|
/// <summary>
|
|
/// Gitea #632 / #638 live coverage: the S7 STRING / WSTRING / CHAR / WCHAR / DTL / DATE / TOD / TIME /
|
|
/// S5TIME codecs against the python-snap7 S7-1500 profile, over real ISO-on-TCP + S7comm, plus the
|
|
/// TSAP override keys (<c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c>) and the negotiated
|
|
/// PDU size. Wide/date-time types are byte-anchored (DBB) by driver rule. Read seeds live at DB1 800..960 in <c>Docker/profiles/s7_1500.json</c> and are all
|
|
/// distinct and non-zero, so a decode that returned zero/default cannot pass. Write tests use the
|
|
/// unseeded DB1 1100..1199 scratch area and additionally need <c>S7_SIM_ALLOW_WRITES=1</c>. Everything
|
|
/// skips cleanly when <c>S7_SIM_ENDPOINT</c> (default the shared Docker host) is unreachable.
|
|
/// </summary>
|
|
[Collection(Snap7ServerCollection.Name)]
|
|
[Trait("Category", "Integration")]
|
|
[Trait("Device", "S7_1500")]
|
|
public sealed class S7_1500CodecTests(Snap7ServerFixture sim)
|
|
{
|
|
private const uint BadOutOfRange = 0x803C0000u;
|
|
|
|
private static S7DriverOptions Options(string host, int port, params S7TagDefinition[] tags) => new()
|
|
{
|
|
Host = host,
|
|
Port = port,
|
|
CpuType = S7CpuType.S71500,
|
|
Rack = 0,
|
|
Slot = 0,
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
Probe = new S7ProbeOptions { Enabled = false },
|
|
RawTags = S7RawTags.Entries(tags),
|
|
};
|
|
|
|
private static S7DriverOptions WithTsap(S7DriverOptions o, string? local, string? remote, S7ConnectionType? ct) => new()
|
|
{
|
|
Host = o.Host, Port = o.Port, CpuType = o.CpuType, Rack = o.Rack, Slot = o.Slot, Timeout = o.Timeout,
|
|
Probe = o.Probe, RawTags = o.RawTags,
|
|
LocalTsap = local, RemoteTsap = remote, ConnectionType = ct,
|
|
};
|
|
|
|
private void RequireSim()
|
|
{
|
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
|
}
|
|
|
|
private static void RequireWrites()
|
|
{
|
|
if (Environment.GetEnvironmentVariable("S7_SIM_ALLOW_WRITES") != "1")
|
|
Assert.Skip("Write test: set S7_SIM_ALLOW_WRITES=1 to allow writes to the endpoint under test (simulator only).");
|
|
}
|
|
|
|
// ---- reads ----
|
|
|
|
/// <summary>Every codec decodes its seeded, distinct non-zero wire bytes through real S7comm.</summary>
|
|
[Fact]
|
|
public async Task Codecs_decode_seeded_bytes_through_real_S7comm()
|
|
{
|
|
RequireSim();
|
|
var o = Options(sim.Host, sim.Port,
|
|
new S7TagDefinition("Str", "DB1.DBB800", S7DataType.String, StringLength: 32),
|
|
new S7TagDefinition("WStr", "DB1.DBB840", S7DataType.WString, StringLength: 8),
|
|
new S7TagDefinition("Chr", "DB1.DBB870", S7DataType.Char),
|
|
new S7TagDefinition("WChr", "DB1.DBB872", S7DataType.WChar),
|
|
new S7TagDefinition("Dtl", "DB1.DBB880", S7DataType.Dtl),
|
|
new S7TagDefinition("Date", "DB1.DBB900", S7DataType.Date),
|
|
new S7TagDefinition("Tod", "DB1.DBB904", S7DataType.Tod),
|
|
new S7TagDefinition("TimePos", "DB1.DBB910", S7DataType.Time),
|
|
new S7TagDefinition("TimeNeg", "DB1.DBB914", S7DataType.Time),
|
|
new S7TagDefinition("S5Sec", "DB1.DBB920", S7DataType.S5Time),
|
|
new S7TagDefinition("S5Cs", "DB1.DBB922", S7DataType.S5Time),
|
|
new S7TagDefinition("DateMax", "DB1.DBB960", S7DataType.Date));
|
|
await using var drv = new S7Driver(o, "s7-codec-read");
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var names = o.RawTags.Select(t => t.RawPath).ToArray();
|
|
var r = await drv.ReadAsync(names, TestContext.Current.CancellationToken);
|
|
r.Count.ShouldBe(names.Length);
|
|
for (var i = 0; i < r.Count; i++) r[i].StatusCode.ShouldBe(0u, names[i]);
|
|
|
|
object? V(string n) => r[Array.IndexOf(names, n)].Value;
|
|
V("Str").ShouldBe("Siemens S7");
|
|
V("WStr").ShouldBe("Grüße");
|
|
V("Chr").ShouldBe("Q");
|
|
V("WChr").ShouldBe("€");
|
|
V("Dtl").ShouldBe(new DateTime(2024, 3, 15, 13, 45, 56).AddTicks(7_890_123));
|
|
V("Date").ShouldBe(new DateTime(2021, 6, 15));
|
|
V("Tod").ShouldBe(49_556_789);
|
|
V("TimePos").ShouldBe(123_456_789);
|
|
V("TimeNeg").ShouldBe(-86_400_123);
|
|
V("S5Sec").ShouldBe(789_000);
|
|
V("S5Cs").ShouldBe(1_230);
|
|
V("DateMax").ShouldBe(new DateTime(2169, 6, 6));
|
|
}
|
|
|
|
/// <summary>Undecodable buffers surface BadOutOfRange per tag, leave siblings Good, and do not degrade health.</summary>
|
|
[Fact]
|
|
public async Task Undecodable_buffers_read_BadOutOfRange_without_degrading_health()
|
|
{
|
|
RequireSim();
|
|
var o = Options(sim.Host, sim.Port,
|
|
new S7TagDefinition("DtlUninit", "DB1.DBB930", S7DataType.Dtl),
|
|
new S7TagDefinition("S5NonBcd", "DB1.DBB940", S7DataType.S5Time),
|
|
new S7TagDefinition("TodPastMidnight", "DB1.DBB944", S7DataType.Tod),
|
|
new S7TagDefinition("WStrCurOverMax", "DB1.DBB950", S7DataType.WString, StringLength: 3),
|
|
new S7TagDefinition("Good", "DB1.DBB904", S7DataType.Tod));
|
|
await using var drv = new S7Driver(o, "s7-codec-bad");
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var r = await drv.ReadAsync(
|
|
["DtlUninit", "S5NonBcd", "TodPastMidnight", "WStrCurOverMax", "Good"],
|
|
TestContext.Current.CancellationToken);
|
|
for (var i = 0; i < 4; i++) r[i].StatusCode.ShouldBe(BadOutOfRange, $"tag #{i}");
|
|
r[4].StatusCode.ShouldBe(0u);
|
|
r[4].Value.ShouldBe(49_556_789);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a PLC buffer the codec refuses is data, not a comms fault");
|
|
}
|
|
|
|
// ---- writes ----
|
|
|
|
private static readonly S7TagDefinition[] ScratchTags =
|
|
[
|
|
new("Str", "DB1.DBB1100", S7DataType.String, StringLength: 8),
|
|
new("WStr", "DB1.DBB1120", S7DataType.WString, StringLength: 6),
|
|
new("Chr", "DB1.DBB1150", S7DataType.Char),
|
|
new("WChr", "DB1.DBB1152", S7DataType.WChar),
|
|
new("Dtl", "DB1.DBB1160", S7DataType.Dtl),
|
|
new("Date", "DB1.DBB1180", S7DataType.Date),
|
|
new("Tod", "DB1.DBB1184", S7DataType.Tod),
|
|
new("Time", "DB1.DBB1188", S7DataType.Time),
|
|
new("S5", "DB1.DBB1192", S7DataType.S5Time),
|
|
];
|
|
|
|
/// <summary>Write each codec's value, read it back through the driver (distinct non-zero values).</summary>
|
|
[Fact]
|
|
public async Task Codec_writes_round_trip_through_real_S7comm()
|
|
{
|
|
RequireSim();
|
|
RequireWrites();
|
|
await using var drv = new S7Driver(Options(sim.Host, sim.Port, ScratchTags), "s7-codec-write");
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var dtl = new DateTime(2031, 11, 30, 23, 59, 58).AddTicks(1_234_567);
|
|
(string Tag, object Value)[] cases =
|
|
[
|
|
("Str", "Plant-7"),
|
|
("WStr", "Ünï€9"),
|
|
("Chr", "Z"),
|
|
("WChr", "ß"),
|
|
("Dtl", dtl),
|
|
("Date", new DateTime(2077, 2, 28)),
|
|
("Tod", 3_723_456),
|
|
("Time", -7_654_321),
|
|
("S5", 456_000),
|
|
];
|
|
var res = await drv.WriteAsync(
|
|
[.. cases.Select(c => new WriteRequest(c.Tag, c.Value))], TestContext.Current.CancellationToken);
|
|
for (var i = 0; i < res.Count; i++) res[i].StatusCode.ShouldBe(0u, cases[i].Tag);
|
|
|
|
var read = await drv.ReadAsync([.. cases.Select(c => c.Tag)], TestContext.Current.CancellationToken);
|
|
for (var i = 0; i < read.Count; i++)
|
|
{
|
|
read[i].StatusCode.ShouldBe(0u, cases[i].Tag);
|
|
read[i].Value.ShouldBe(cases[i].Value, cases[i].Tag);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unrepresentable writes are refused BadOutOfRange with nothing sent (the previously written value
|
|
/// survives) and without degrading driver health. Covers classic STRING + DT (fixed in this change:
|
|
/// they used to map to BadCommunicationError) and every #632 codec.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Unrepresentable_writes_are_BadOutOfRange_and_leave_the_PLC_untouched()
|
|
{
|
|
RequireSim();
|
|
RequireWrites();
|
|
var tags = ScratchTags.Append(new S7TagDefinition("Dt", "DB1.DBB1196", S7DataType.DateTime)).ToArray();
|
|
await using var drv = new S7Driver(Options(sim.Host, sim.Port, tags), "s7-codec-range");
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
var ct = TestContext.Current.CancellationToken;
|
|
|
|
// Known-good baseline so "nothing was sent" is observable as an unchanged value.
|
|
var good = new Dictionary<string, object>
|
|
{
|
|
["Str"] = "Keep-8ch", ["WStr"] = "Keep-6", ["Chr"] = "K", ["WChr"] = "W",
|
|
["Dtl"] = new DateTime(2025, 5, 6, 7, 8, 9), ["Date"] = new DateTime(2000, 1, 2),
|
|
["Tod"] = 1_000_001, ["Time"] = 2_000_002, ["S5"] = 3_000,
|
|
};
|
|
(await drv.WriteAsync([.. good.Select(g => new WriteRequest(g.Key, g.Value))], ct))
|
|
.ShouldAllBe(x => x.StatusCode == 0u);
|
|
|
|
(string Tag, object Bad)[] bad =
|
|
[
|
|
("Str", "123456789"), // 9 > max 8
|
|
("WStr", "1234567"), // 7 > max 6
|
|
("Chr", "€"), // U+20AC does not fit Latin-1
|
|
("Dtl", new DateTime(1960, 1, 1)), // before 1970
|
|
("Date", new DateTime(1980, 1, 1)), // before 1990
|
|
("Tod", 86_400_000), // past midnight
|
|
("Time", 3_000_000_000L), // > Int32
|
|
("S5", 15), // fits no S5TIME time base exactly
|
|
("Dt", new DateTime(1980, 1, 1)), // outside the 1990-2089 DT range
|
|
];
|
|
var res = await drv.WriteAsync([.. bad.Select(b => new WriteRequest(b.Tag, b.Bad))], ct);
|
|
for (var i = 0; i < res.Count; i++) res[i].StatusCode.ShouldBe(BadOutOfRange, bad[i].Tag);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
|
|
var back = await drv.ReadAsync([.. good.Keys], ct);
|
|
var keys = good.Keys.ToArray();
|
|
for (var i = 0; i < back.Count; i++)
|
|
{
|
|
back[i].StatusCode.ShouldBe(0u, keys[i]);
|
|
back[i].Value.ShouldBe(good[keys[i]], $"{keys[i]} must be unchanged after the refused write");
|
|
}
|
|
}
|
|
|
|
// ---- TSAP override + PDU ----
|
|
|
|
/// <summary>
|
|
/// The TSAP override keys reach the wire. snap7's server accepts any TSAP, so this proves the
|
|
/// override is applied and the connection still works — NOT TSAP semantics (a real CPU rejects a
|
|
/// wrong TSAP; only hardware can prove the values themselves).
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("01.00", "03.01", null)]
|
|
[InlineData("4D.57", null, null)]
|
|
[InlineData(null, "02.01", null)]
|
|
[InlineData(null, null, S7ConnectionType.PG)]
|
|
[InlineData(null, null, S7ConnectionType.OP)]
|
|
public async Task TSAP_override_keys_connect_and_read_through_real_S7comm(
|
|
string? local, string? remote, S7ConnectionType? connType)
|
|
{
|
|
RequireSim();
|
|
var o = WithTsap(
|
|
Options(sim.Host, sim.Port, new S7TagDefinition("Probe", "DB1.DBW0", S7DataType.UInt16)),
|
|
local, remote, connType);
|
|
await using var drv = new S7Driver(o, "s7-tsap");
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var r = await drv.ReadAsync(["Probe"], TestContext.Current.CancellationToken);
|
|
r[0].StatusCode.ShouldBe(0u);
|
|
Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
|
|
}
|
|
|
|
/// <summary>The negotiated PDU size is a real S7comm value after connect and 0 after shutdown.</summary>
|
|
[Fact]
|
|
public async Task Negotiated_PDU_size_is_observed_after_connect()
|
|
{
|
|
RequireSim();
|
|
var o = Options(sim.Host, sim.Port, new S7TagDefinition("Probe", "DB1.DBW0", S7DataType.UInt16));
|
|
await using var drv = new S7Driver(o, "s7-pdu");
|
|
drv.NegotiatedPduSize.ShouldBe(0, "no connect yet");
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
// S7comm PDUs are 240 (S7-300) / 480 (S7-1200/1500 default) / up to 960 bytes.
|
|
drv.NegotiatedPduSize.ShouldBeInRange(240, 960);
|
|
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(drv.NegotiatedPduSize);
|
|
|
|
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
|
drv.NegotiatedPduSize.ShouldBe(0);
|
|
}
|
|
}
|