Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500CodecTests.cs
T

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);
}
}