Merge b626-s7 (batch #626+)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+13
@@ -145,4 +145,17 @@ public sealed class WriteCommandParseValueTests
|
||||
{
|
||||
ReadCommand.SynthesiseTagName(address, type).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>The #288/#289 types parse to the CLR shape the codecs accept.</summary>
|
||||
[Fact]
|
||||
public void ParseValue_handles_the_string_and_date_time_codec_types()
|
||||
{
|
||||
WriteCommand.ParseValue("héllo", S7DataType.WString).ShouldBe("héllo");
|
||||
WriteCommand.ParseValue("A", S7DataType.Char).ShouldBe("A");
|
||||
WriteCommand.ParseValue("2008-12-16T20:30:20", S7DataType.Dtl).ShouldBeOfType<DateTime>();
|
||||
WriteCommand.ParseValue("2009-12-01", S7DataType.Date).ShouldBeOfType<DateTime>();
|
||||
WriteCommand.ParseValue("86399999", S7DataType.Tod).ShouldBe(86399999L);
|
||||
WriteCommand.ParseValue("-1000", S7DataType.Time).ShouldBe(-1000L);
|
||||
WriteCommand.ParseValue("127000", S7DataType.S5Time).ShouldBe(127000L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using S7NetDataType = global::S7.Net.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TSAP / connection-type override (Gitea #295) and negotiated-PDU surfacing (Gitea #294).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7TsapAndPduTests
|
||||
{
|
||||
// ---- TSAP text ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("01.00", 0x0100)]
|
||||
[InlineData("03.01", 0x0301)]
|
||||
[InlineData("3.1", 0x0301)]
|
||||
[InlineData("0301", 0x0301)]
|
||||
[InlineData("0x0301", 0x0301)]
|
||||
[InlineData("03:21", 0x0321)]
|
||||
[InlineData(" 4d.57 ", 0x4D57)]
|
||||
public void Tsap_text_parses(string text, int expected)
|
||||
{
|
||||
S7Tsap.TryParse(text, out var v).ShouldBeTrue();
|
||||
v.ShouldBe((ushort)expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("zz.00")]
|
||||
[InlineData("100.00")]
|
||||
[InlineData("01.00.00")]
|
||||
[InlineData("12345")]
|
||||
[InlineData("-1")]
|
||||
public void Tsap_garbage_is_rejected(string text) => S7Tsap.TryParse(text, out _).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void Tsap_formats_canonically() => S7Tsap.Format(0x0301).ShouldBe("03.01");
|
||||
|
||||
// ---- resolution ----
|
||||
|
||||
private static (byte, byte, byte, byte) Bytes(S7DriverOptions o)
|
||||
{
|
||||
var p = S7Tsap.Resolve(o).ShouldNotBeNull();
|
||||
return (p.Local.FirstByte, p.Local.SecondByte, p.Remote.FirstByte, p.Remote.SecondByte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_override_resolves_to_null_so_the_plain_constructor_is_kept() =>
|
||||
S7Tsap.Resolve(new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2 }).ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Local_override_replaces_only_the_local_tsap()
|
||||
{
|
||||
// S7-300 rack 0 slot 2 default remote = 03.02 (S7.Net slot-derived).
|
||||
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, LocalTsap = "02.00" };
|
||||
Bytes(o).ShouldBe(((byte)2, (byte)0, (byte)3, (byte)2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remote_override_replaces_the_whole_remote_tsap()
|
||||
{
|
||||
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, RemoteTsap = "03.01" };
|
||||
Bytes(o).ShouldBe(((byte)1, (byte)0, (byte)3, (byte)1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7ConnectionType.PG, 1)]
|
||||
[InlineData(S7ConnectionType.OP, 2)]
|
||||
[InlineData(S7ConnectionType.S7Basic, 3)]
|
||||
public void Connection_type_sets_only_the_first_remote_byte_and_keeps_rack_slot(S7ConnectionType ct, int first)
|
||||
{
|
||||
// rack 1 slot 3 → second byte 1*0x20 + 3 = 0x23.
|
||||
var o = new S7DriverOptions { CpuType = S7CpuType.S7400, Rack = 1, Slot = 3, ConnectionType = ct };
|
||||
var (_, _, rFirst, rSecond) = Bytes(o);
|
||||
rFirst.ShouldBe((byte)first);
|
||||
rSecond.ShouldBe((byte)0x23);
|
||||
}
|
||||
|
||||
// ---- factory JSON ----
|
||||
|
||||
[Fact]
|
||||
public void Json_accepts_string_number_and_enum_name_and_normalises()
|
||||
{
|
||||
var o = S7DriverFactoryExtensions.ParseOptions("d",
|
||||
"""{"host":"h","localTsap":"1.0","remoteTsap":769,"cpuType":"S7300"}""");
|
||||
o.LocalTsap.ShouldBe("01.00");
|
||||
o.RemoteTsap.ShouldBe("03.01");
|
||||
o.ConnectionType.ShouldBeNull();
|
||||
|
||||
var ct = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h","connectionType":"pg"}""");
|
||||
ct.ConnectionType.ShouldBe(S7ConnectionType.PG);
|
||||
ct.RemoteTsap.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Json_without_the_keys_leaves_the_defaults()
|
||||
{
|
||||
var o = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h"}""");
|
||||
o.LocalTsap.ShouldBeNull();
|
||||
o.RemoteTsap.ShouldBeNull();
|
||||
o.ConnectionType.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"host":"h","localTsap":"nope"}""")]
|
||||
[InlineData("""{"host":"h","remoteTsap":"01.00.00"}""")]
|
||||
[InlineData("""{"host":"h","remoteTsap":99999999}""")]
|
||||
[InlineData("""{"host":"h","connectionType":"Bogus"}""")]
|
||||
[InlineData("""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}""")]
|
||||
public void Json_rejects_bad_tsap_settings(string json) =>
|
||||
Should.Throw<InvalidOperationException>(() => S7DriverFactoryExtensions.ParseOptions("d", json));
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_hands_the_full_options_to_the_factory()
|
||||
{
|
||||
var factory = new CapturingFactory();
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RemoteTsap = "03.01",
|
||||
LocalTsap = "01.00",
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-tsap", factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
factory.Seen.ShouldNotBeNull().RemoteTsap.ShouldBe("03.01");
|
||||
factory.Seen!.LocalTsap.ShouldBe("01.00");
|
||||
}
|
||||
|
||||
// ---- PDU ----
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiated_pdu_size_is_surfaced_after_connect_and_cleared_on_shutdown()
|
||||
{
|
||||
var factory = new CapturingFactory { Pdu = 480 };
|
||||
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
|
||||
using var drv = new S7Driver(opts, "s7-pdu", factory);
|
||||
drv.NegotiatedPduSize.ShouldBe(0);
|
||||
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(0);
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
drv.NegotiatedPduSize.ShouldBe(480);
|
||||
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(480);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.NegotiatedPduSize.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_plc_that_does_not_report_a_pdu_size_yields_zero_not_a_throw()
|
||||
{
|
||||
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
|
||||
using var drv = new S7Driver(opts, "s7-pdu0", new ConnectingFakeS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
drv.NegotiatedPduSize.ShouldBe(0);
|
||||
}
|
||||
|
||||
private sealed class CapturingFactory : IS7PlcFactory
|
||||
{
|
||||
public S7DriverOptions? Seen { get; private set; }
|
||||
public int Pdu { get; init; }
|
||||
|
||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
|
||||
new Plc(Pdu);
|
||||
|
||||
public IS7Plc Create(S7DriverOptions options)
|
||||
{
|
||||
Seen = options;
|
||||
return new Plc(Pdu);
|
||||
}
|
||||
|
||||
private sealed class Plc(int pdu) : IS7Plc
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public int MaxPduSize => IsConnected ? pdu : 0;
|
||||
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
|
||||
public void Close() => IsConnected = false;
|
||||
public Task<object?> ReadAsync(string a, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int s, int c, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
|
||||
List<global::S7.Net.Types.DataItem> i, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task WriteAsync(string a, object v, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task WriteBytesAsync(S7NetDataType area, int db, int s, byte[] v, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public void Dispose() => IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_conflict_message_matches_the_shared_detail()
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(() => S7DriverFactoryExtensions.ParseOptions("d",
|
||||
"""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}"""));
|
||||
ex.Message.ShouldContain(S7DriverOptions.TsapConflictDetail);
|
||||
}
|
||||
|
||||
// ---- Test Connect data-access check (PUT/GET) ----
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_data_access_succeeds_when_the_read_works() =>
|
||||
(await S7DriverProbe.CheckDataAccessAsync(_ => Task.FromResult(new byte[2]), TestContext.Current.CancellationToken))
|
||||
.ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_fails_with_actionable_message_when_put_get_is_disabled()
|
||||
{
|
||||
var denied = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
|
||||
new Exception("Accessing object not allowed"));
|
||||
var r = await S7DriverProbe.CheckDataAccessAsync(_ => throw denied, TestContext.Current.CancellationToken);
|
||||
r.ShouldNotBeNull().Ok.ShouldBeFalse();
|
||||
r.Message!.ShouldContain("PUT/GET");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_tolerates_an_address_not_available_read_error()
|
||||
{
|
||||
var missing = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, new Exception("Object does not exist"));
|
||||
(await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_reports_any_other_read_fault_as_a_failure()
|
||||
{
|
||||
var r = await S7DriverProbe.CheckDataAccessAsync(
|
||||
_ => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ConnectionError, "socket dropped"),
|
||||
TestContext.Current.CancellationToken);
|
||||
r.ShouldNotBeNull().Ok.ShouldBeFalse();
|
||||
r.Message!.ShouldContain("socket dropped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using S7NetDataType = global::S7.Net.DataType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Known-byte-vector tests for the S7 string/char/date-time/duration codecs (Gitea #288, #289) —
|
||||
/// the pure <see cref="S7ValueCodecs"/> plus the driver's dispatch
|
||||
/// (<see cref="S7Driver.ScalarByteWidth"/> / <c>DecodeScalarBlock</c> / <c>EncodeScalarBlock</c>) and one
|
||||
/// over-the-seam read/write pass through a memory-backed fake PLC proving status mapping.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7ValueCodecTests
|
||||
{
|
||||
private static S7TagDefinition Tag(S7DataType dt, int stringLength = 254, string address = "DB1.DBB0") =>
|
||||
new("T", address, dt, StringLength: stringLength);
|
||||
|
||||
private static S7ParsedAddress Addr() =>
|
||||
new(S7Area.DataBlock, DbNumber: 1, S7Size.Byte, ByteOffset: 0, BitOffset: 0);
|
||||
|
||||
private static object Decode(S7DataType dt, byte[] block, int stringLength = 254) =>
|
||||
S7Driver.DecodeScalarBlock(Tag(dt, stringLength), Addr(), block);
|
||||
|
||||
private static byte[] Encode(S7DataType dt, object? value, int stringLength = 254) =>
|
||||
S7Driver.EncodeScalarBlock(Tag(dt, stringLength), value);
|
||||
|
||||
// ---- widths ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7DataType.Char, 1)]
|
||||
[InlineData(S7DataType.WChar, 2)]
|
||||
[InlineData(S7DataType.Date, 2)]
|
||||
[InlineData(S7DataType.S5Time, 2)]
|
||||
[InlineData(S7DataType.Tod, 4)]
|
||||
[InlineData(S7DataType.Time, 4)]
|
||||
[InlineData(S7DataType.Dtl, 12)]
|
||||
public void Fixed_widths(S7DataType dt, int width) =>
|
||||
S7Driver.ScalarByteWidth(Tag(dt), Addr()).ShouldBe(width);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 4)]
|
||||
[InlineData(1, 6)]
|
||||
[InlineData(5, 14)]
|
||||
[InlineData(16382, 32768)]
|
||||
public void WString_width_is_header_plus_two_bytes_per_char(int maxLen, int width) =>
|
||||
S7Driver.ScalarByteWidth(Tag(S7DataType.WString, maxLen), Addr()).ShouldBe(width);
|
||||
|
||||
// ---- WSTRING ----
|
||||
|
||||
[Fact]
|
||||
public void WString_encode_known_vector()
|
||||
{
|
||||
// maxLen=5, curLen=2, "Hi" UTF-16BE, 3 zero-padded chars.
|
||||
Encode(S7DataType.WString, "Hi", 5).ShouldBe(
|
||||
[0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WString_decode_ignores_padding_past_current_length()
|
||||
{
|
||||
byte[] block = [0x00, 0x05, 0x00, 0x02, 0x00, 0x48, 0x00, 0x69, 0x00, 0x58, 0x00, 0x58, 0, 0];
|
||||
Decode(S7DataType.WString, block, 5).ShouldBe("Hi");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("A")]
|
||||
[InlineData("Grüße Ω")]
|
||||
[InlineData("12345678")] // exactly at the declared max
|
||||
public void WString_round_trips_including_boundary_lengths(string s)
|
||||
{
|
||||
var bytes = Encode(S7DataType.WString, s, 8);
|
||||
bytes.Length.ShouldBe(4 + 16);
|
||||
Decode(S7DataType.WString, bytes, 8).ShouldBe(s);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WString_empty_with_zero_max_is_a_bare_header()
|
||||
{
|
||||
var bytes = Encode(S7DataType.WString, "", 0);
|
||||
bytes.ShouldBe([0, 0, 0, 0]);
|
||||
Decode(S7DataType.WString, bytes, 0).ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WString_write_over_declared_max_is_refused_not_truncated() =>
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.WString, "123456789", 8));
|
||||
|
||||
[Fact]
|
||||
public void WString_decode_rejects_curLen_above_maxLen() =>
|
||||
Should.Throw<S7ValueRangeException>(() =>
|
||||
Decode(S7DataType.WString, [0x00, 0x02, 0x00, 0x03, 0, 0x41, 0, 0x42, 0, 0x43], 2));
|
||||
|
||||
[Fact]
|
||||
public void WString_decode_rejects_length_running_past_block() =>
|
||||
Should.Throw<S7ValueRangeException>(() =>
|
||||
Decode(S7DataType.WString, [0x00, 0x05, 0x00, 0x04, 0, 0x41], 5));
|
||||
|
||||
// ---- CHAR / WCHAR ----
|
||||
|
||||
[Fact]
|
||||
public void Char_known_vectors()
|
||||
{
|
||||
Encode(S7DataType.Char, "A").ShouldBe([0x41]);
|
||||
Encode(S7DataType.Char, "é").ShouldBe([0xE9]);
|
||||
Decode(S7DataType.Char, [0x41]).ShouldBe("A");
|
||||
Decode(S7DataType.Char, [0xE9]).ShouldBe("é");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Char_refuses_wide_character_and_wrong_length()
|
||||
{
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Char, "Ā")); // U+0100
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, "AB"));
|
||||
Should.Throw<S7Driver.S7WriteValueException>(() => Encode(S7DataType.Char, ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WChar_known_vectors()
|
||||
{
|
||||
Encode(S7DataType.WChar, "Ω").ShouldBe([0x03, 0xA9]);
|
||||
Encode(S7DataType.WChar, "A").ShouldBe([0x00, 0x41]);
|
||||
Decode(S7DataType.WChar, [0x03, 0xA9]).ShouldBe("Ω");
|
||||
}
|
||||
|
||||
// ---- DTL ----
|
||||
|
||||
// Siemens documentation example: DTL#2008-12-16-20:30:20.250 (a Tuesday → S7 weekday 3).
|
||||
private static readonly byte[] DtlVector = [0x07, 0xD8, 0x0C, 0x10, 0x03, 0x14, 0x1E, 0x14, 0x0E, 0xE6, 0xB2, 0x80];
|
||||
|
||||
[Fact]
|
||||
public void Dtl_known_vector_both_ways()
|
||||
{
|
||||
var expected = new DateTime(2008, 12, 16, 20, 30, 20, 250);
|
||||
Decode(S7DataType.Dtl, DtlVector).ShouldBe(expected);
|
||||
Encode(S7DataType.Dtl, expected).ShouldBe(DtlVector);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dtl_keeps_100ns_resolution()
|
||||
{
|
||||
var dt = new DateTime(2030, 1, 2, 3, 4, 5).AddTicks(1234567);
|
||||
Decode(S7DataType.Dtl, Encode(S7DataType.Dtl, dt)).ShouldBe(dt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dtl_uninitialised_zero_buffer_is_rejected_not_year_zero() =>
|
||||
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, new byte[12]));
|
||||
|
||||
[Fact]
|
||||
public void Dtl_invalid_calendar_fields_are_rejected()
|
||||
{
|
||||
var bad = (byte[])DtlVector.Clone();
|
||||
bad[2] = 13; // month 13
|
||||
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
|
||||
bad = (byte[])DtlVector.Clone();
|
||||
bad[8] = 0xFF; // nanoseconds > 999,999,999
|
||||
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Dtl, bad));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1969)]
|
||||
[InlineData(2263)]
|
||||
public void Dtl_write_outside_plc_year_range_is_refused(int year) =>
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Dtl, new DateTime(year, 1, 1)));
|
||||
|
||||
// ---- DT (existing S7DataType.DateTime — regression vector) ----
|
||||
|
||||
[Fact]
|
||||
public void Dt_still_routes_through_the_8_byte_bcd_codec()
|
||||
{
|
||||
var dt = new DateTime(2008, 12, 16, 20, 30, 20, 250);
|
||||
var bytes = Encode(S7DataType.DateTime, dt);
|
||||
bytes.Length.ShouldBe(8);
|
||||
bytes.Take(7).ToArray().ShouldBe([0x08, 0x12, 0x16, 0x20, 0x30, 0x20, 0x25]);
|
||||
Decode(S7DataType.DateTime, bytes).ShouldBe(dt);
|
||||
}
|
||||
|
||||
// ---- DATE ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(1990, 1, 1, 0x0000)]
|
||||
[InlineData(2000, 2, 29, 0x0E7F)]
|
||||
[InlineData(2009, 12, 1, 0x1C6A)]
|
||||
[InlineData(2168, 12, 31, 0xFF62)]
|
||||
public void Date_known_vectors(int y, int m, int d, int days)
|
||||
{
|
||||
byte[] bytes = [(byte)(days >> 8), (byte)days];
|
||||
Decode(S7DataType.Date, bytes).ShouldBe(new DateTime(y, m, d));
|
||||
Encode(S7DataType.Date, new DateTime(y, m, d, 17, 5, 0)).ShouldBe(bytes); // time-of-day dropped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Date_write_outside_uint16_range_is_refused()
|
||||
{
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(1989, 12, 31)));
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Date, new DateTime(2169, 6, 7)));
|
||||
Encode(S7DataType.Date, new DateTime(2169, 6, 6)).ShouldBe([0xFF, 0xFF]);
|
||||
}
|
||||
|
||||
// ---- TOD ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, new byte[] { 0, 0, 0, 0 })]
|
||||
[InlineData(1000, new byte[] { 0, 0, 0x03, 0xE8 })]
|
||||
[InlineData(86_399_999, new byte[] { 0x05, 0x26, 0x5B, 0xFF })] // TOD#23:59:59.999
|
||||
public void Tod_known_vectors(int ms, byte[] bytes)
|
||||
{
|
||||
Decode(S7DataType.Tod, bytes).ShouldBe(ms);
|
||||
Encode(S7DataType.Tod, ms).ShouldBe(bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tod_boundaries_are_refused_on_both_sides()
|
||||
{
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, 86_400_000));
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Tod, -1));
|
||||
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.Tod, [0x05, 0x26, 0x5C, 0x00]));
|
||||
Encode(S7DataType.Tod, TimeSpan.FromHours(1)).ShouldBe([0, 0x36, 0xEE, 0x80]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fractional_millisecond_double_rounds_like_the_other_int32_writes()
|
||||
{
|
||||
// Pinned: matches BoxValueForWrite's Convert.ToInt32 behaviour (banker's), see docs.
|
||||
Encode(S7DataType.Tod, 100.7).ShouldBe(Encode(S7DataType.Tod, 101));
|
||||
Encode(S7DataType.Time, 100.7).ShouldBe(Encode(S7DataType.Time, 101));
|
||||
Encode(S7DataType.S5Time, 99.7).ShouldBe(Encode(S7DataType.S5Time, 100));
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 100.7)); // rounds to 101: off the grid
|
||||
}
|
||||
|
||||
// ---- TIME ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(86_400_000, new byte[] { 0x05, 0x26, 0x5C, 0x00 })] // T#1d
|
||||
[InlineData(-1000, new byte[] { 0xFF, 0xFF, 0xFC, 0x18 })] // T#-1s
|
||||
[InlineData(int.MaxValue, new byte[] { 0x7F, 0xFF, 0xFF, 0xFF })] // T#24d20h31m23s647ms
|
||||
[InlineData(int.MinValue, new byte[] { 0x80, 0, 0, 0 })]
|
||||
public void Time_known_vectors(int ms, byte[] bytes)
|
||||
{
|
||||
Decode(S7DataType.Time, bytes).ShouldBe(ms);
|
||||
Encode(S7DataType.Time, ms).ShouldBe(bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Time_outside_int32_is_refused() =>
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.Time, (long)int.MaxValue + 1));
|
||||
|
||||
// ---- S5TIME ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x0000, 0)]
|
||||
[InlineData(0x0999, 9_990)] // 999 × 10 ms
|
||||
[InlineData(0x1500, 50_000)] // 500 × 100 ms
|
||||
[InlineData(0x2127, 127_000)] // W#16#2127 = 127 s (Siemens docs)
|
||||
[InlineData(0x3999, 9_990_000)] // 999 × 10 s, the maximum
|
||||
[InlineData(0xC127, 1_270)] // bits 15-14 are ignored (base 00, 127 × 10 ms)
|
||||
public void S5Time_decode_known_vectors(int word, int ms) =>
|
||||
Decode(S7DataType.S5Time, [(byte)(word >> 8), (byte)word]).ShouldBe(ms);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0x0000)]
|
||||
[InlineData(9_990, 0x0999)] // finest base that fits
|
||||
[InlineData(10_000, 0x1100)] // 100 × 100 ms
|
||||
[InlineData(127_000, 0x2127)]
|
||||
[InlineData(9_990_000, 0x3999)]
|
||||
public void S5Time_encode_picks_the_finest_base_that_fits(int ms, int word) =>
|
||||
Encode(S7DataType.S5Time, ms).ShouldBe([(byte)(word >> 8), (byte)word]);
|
||||
|
||||
[Fact]
|
||||
public void S5Time_non_bcd_nibble_is_rejected_on_read() =>
|
||||
Should.Throw<S7ValueRangeException>(() => Decode(S7DataType.S5Time, [0x01, 0x2A]));
|
||||
|
||||
[Fact]
|
||||
public void S5Time_unrepresentable_writes_are_refused()
|
||||
{
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 9_990_001)); // above max
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, -10));
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 15)); // not a multiple of 10 ms
|
||||
Should.Throw<S7ValueRangeException>(() => Encode(S7DataType.S5Time, 1_000_500)); // no base holds it exactly
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void S5Time_round_trips_every_representable_value_in_a_sample()
|
||||
{
|
||||
foreach (var ms in new[] { 10, 990, 9_990, 10_000, 99_900, 100_000, 999_000, 1_000_000, 9_990_000 })
|
||||
Decode(S7DataType.S5Time, Encode(S7DataType.S5Time, ms)).ShouldBe(ms);
|
||||
}
|
||||
|
||||
// ---- type mapping ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7DataType.WString, DriverDataType.String)]
|
||||
[InlineData(S7DataType.Char, DriverDataType.String)]
|
||||
[InlineData(S7DataType.WChar, DriverDataType.String)]
|
||||
[InlineData(S7DataType.Dtl, DriverDataType.DateTime)]
|
||||
[InlineData(S7DataType.Date, DriverDataType.DateTime)]
|
||||
[InlineData(S7DataType.Tod, DriverDataType.Int32)]
|
||||
[InlineData(S7DataType.Time, DriverDataType.Int32)]
|
||||
[InlineData(S7DataType.S5Time, DriverDataType.Int32)]
|
||||
public async Task Discovery_maps_new_types_onto_existing_driver_types(S7DataType dt, DriverDataType expected)
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(Tag(dt, 10) with { Name = "X" }),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-map", new ConnectingFakeS7PlcFactory());
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
builder.Variables.Single().Attr.DriverDataType.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
|
||||
{
|
||||
S7DriverOptions With(S7TagDefinition t) => new()
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(t),
|
||||
};
|
||||
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
|
||||
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
|
||||
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
// ---- TagConfig ----
|
||||
|
||||
[Fact]
|
||||
public void TagConfig_accepts_wstring_length_up_to_16382_but_string_stays_254()
|
||||
{
|
||||
S7TagDefinitionFactory.FromTagConfig(
|
||||
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16382}""", "p", out var d).ShouldBeTrue();
|
||||
d.DataType.ShouldBe(S7DataType.WString);
|
||||
d.StringLength.ShouldBe(16382);
|
||||
S7TagDefinitionFactory.FromTagConfig(
|
||||
"""{"address":"DB1.DBB0","dataType":"WString","stringLength":16383}""", "p", out _).ShouldBeFalse();
|
||||
S7TagDefinitionFactory.FromTagConfig(
|
||||
"""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""", "p", out _).ShouldBeFalse();
|
||||
S7TagDefinitionFactory.Inspect("""{"address":"DB1.DBB0","dataType":"WString","stringLength":99999}""")
|
||||
.ShouldHaveSingleItem().ShouldContain("16382");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Char")]
|
||||
[InlineData("WChar")]
|
||||
[InlineData("Dtl")]
|
||||
[InlineData("Date")]
|
||||
[InlineData("Tod")]
|
||||
[InlineData("Time")]
|
||||
[InlineData("S5Time")]
|
||||
public void TagConfig_round_trips_every_new_type_name(string name)
|
||||
{
|
||||
var dt = Enum.Parse<S7DataType>(name);
|
||||
var cfg = S7TagDefinitionFactory.ToTagConfig(Tag(dt));
|
||||
S7TagDefinitionFactory.FromTagConfig(cfg, "p", out var back).ShouldBeTrue();
|
||||
back.DataType.ShouldBe(dt);
|
||||
}
|
||||
|
||||
// ---- over the seam: status mapping ----
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_reads_writes_and_maps_range_errors_without_degrading_health()
|
||||
{
|
||||
var plc = new MemoryS7Plc();
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("Ws", "DB1.DBB0", S7DataType.WString, StringLength: 4),
|
||||
new S7TagDefinition("Dtl", "DB1.DBB100", S7DataType.Dtl),
|
||||
new S7TagDefinition("S5", "DB1.DBB120", S7DataType.S5Time)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-codec-e2e", new MemoryS7PlcFactory(plc));
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Good write then read-back.
|
||||
(await drv.WriteAsync([new WriteRequest("Ws", "abcd")], ct))[0].StatusCode.ShouldBe(0u);
|
||||
plc.Mem.Take(12).ToArray().ShouldBe([0, 4, 0, 4, 0, 0x61, 0, 0x62, 0, 0x63, 0, 0x64]);
|
||||
var r = await drv.ReadAsync(["Ws"], ct);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
r[0].Value.ShouldBe("abcd");
|
||||
|
||||
// Over-length write → BadOutOfRange, nothing written, health unharmed.
|
||||
(await drv.WriteAsync([new WriteRequest("Ws", "abcde")], ct))[0].StatusCode.ShouldBe(0x803C0000u);
|
||||
plc.Mem.Skip(5).Take(1).Single().ShouldBe((byte)0x61);
|
||||
|
||||
// Uninitialised DTL (all zero) reads BadOutOfRange, not a comms fault; the sibling stays Good.
|
||||
var reads = await drv.ReadAsync(["Dtl", "Ws"], ct);
|
||||
reads[0].StatusCode.ShouldBe(0x803C0000u);
|
||||
reads[1].StatusCode.ShouldBe(0u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
// S5TIME value that fits no base → BadOutOfRange; wrong-shape value → BadTypeMismatch.
|
||||
(await drv.WriteAsync([new WriteRequest("S5", 15)], ct))[0].StatusCode.ShouldBe(0x803C0000u);
|
||||
(await drv.WriteAsync([new WriteRequest("S5", "soon")], ct))[0].StatusCode.ShouldBe(0x80740000u);
|
||||
(await drv.WriteAsync([new WriteRequest("S5", 127_000)], ct))[0].StatusCode.ShouldBe(0u);
|
||||
(await drv.ReadAsync(["S5"], ct))[0].Value.ShouldBe(127_000);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
// ---- fakes ----
|
||||
|
||||
private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory
|
||||
{
|
||||
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) => plc;
|
||||
}
|
||||
|
||||
/// <summary>Single-DB memory-backed fake: block reads/writes address one flat byte array.</summary>
|
||||
internal sealed class MemoryS7Plc : IS7Plc
|
||||
{
|
||||
public byte[] Mem { get; } = new byte[512];
|
||||
public bool IsConnected { get; private set; }
|
||||
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
|
||||
public void Close() => IsConnected = false;
|
||||
public Task<object?> ReadAsync(string address, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int start, int count, CancellationToken ct) =>
|
||||
Task.FromResult(Mem.AsSpan(start, count).ToArray());
|
||||
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
|
||||
List<global::S7.Net.Types.DataItem> items, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task WriteAsync(string address, object value, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task WriteBytesAsync(S7NetDataType area, int db, int start, byte[] value, CancellationToken ct)
|
||||
{
|
||||
value.CopyTo(Mem, start);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public void Dispose() => IsConnected = false;
|
||||
}
|
||||
}
|
||||
@@ -105,4 +105,61 @@ public sealed class S7DriverPageFormSerializationTests
|
||||
roundTripped.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(4));
|
||||
roundTripped.ProbeTimeoutSeconds.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FormModel_RoundTrip_PreservesTsapOverrides()
|
||||
{
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.168.1.50",
|
||||
LocalTsap = "01.00",
|
||||
RemoteTsap = "03.01",
|
||||
};
|
||||
var back = S7DriverForm.FormModel.FromOptions(opts).ToOptions();
|
||||
back.LocalTsap.ShouldBe("01.00");
|
||||
back.RemoteTsap.ShouldBe("03.01");
|
||||
back.ConnectionType.ShouldBeNull();
|
||||
|
||||
var withType = S7DriverForm.FormModel.FromOptions(new S7DriverOptions { ConnectionType = S7ConnectionType.OP }).ToOptions();
|
||||
withType.ConnectionType.ShouldBe(S7ConnectionType.OP);
|
||||
|
||||
// Defaults stay absent: a blank form must not invent an override.
|
||||
var blank = S7DriverForm.FormModel.FromOptions(new S7DriverOptions()).ToOptions();
|
||||
blank.LocalTsap.ShouldBeNull();
|
||||
blank.RemoteTsap.ShouldBeNull();
|
||||
blank.ConnectionType.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionType_serialises_as_a_string_the_factory_binds()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(
|
||||
new S7DriverOptions { ConnectionType = S7ConnectionType.PG },
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } });
|
||||
json.ShouldContain("\"connectionType\":\"PG\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Numeric_tsap_loads_into_the_form_model_instead_of_falling_back_to_defaults()
|
||||
{
|
||||
// The factory accepts a JSON number; the form binds the same document the same way, so a Save
|
||||
// cannot silently replace the authored config with defaults.
|
||||
var opts = JsonSerializer.Deserialize<S7DriverOptions>(
|
||||
"""{"remoteTsap":769,"localTsap":"01.00","cpuType":"S7300"}""",
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } });
|
||||
opts.ShouldNotBeNull();
|
||||
var back = S7DriverForm.FormModel.FromOptions(opts).ToOptions();
|
||||
back.RemoteTsap.ShouldBe("03.01");
|
||||
back.LocalTsap.ShouldBe("01.00");
|
||||
back.CpuType.ShouldBe(S7CpuType.S7300);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Form_reports_the_factory_conflict_error_for_remote_tsap_plus_connection_type()
|
||||
{
|
||||
var form = new S7DriverForm.FormModel { RemoteTsap = "03.01", ConnectionTypeText = "PG" };
|
||||
form.TsapConflictError().ShouldNotBeNull().ShouldContain(S7DriverOptions.TsapConflictDetail);
|
||||
new S7DriverForm.FormModel { RemoteTsap = "03.01" }.TsapConflictError().ShouldBeNull();
|
||||
new S7DriverForm.FormModel { ConnectionTypeText = "PG" }.TsapConflictError().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,14 @@ public sealed class S7TagConfigModelTests
|
||||
[InlineData("Float64")]
|
||||
[InlineData("String")]
|
||||
[InlineData("DateTime")]
|
||||
[InlineData("WString")]
|
||||
[InlineData("Char")]
|
||||
[InlineData("WChar")]
|
||||
[InlineData("Dtl")]
|
||||
[InlineData("Date")]
|
||||
[InlineData("Tod")]
|
||||
[InlineData("Time")]
|
||||
[InlineData("S5Time")]
|
||||
public void Validate_rejects_wide_type_arrays(string dataType)
|
||||
{
|
||||
var m = S7TagConfigModel.FromJson(
|
||||
|
||||
Reference in New Issue
Block a user