diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..0d138107 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,80 @@ +name: Test + +on: + [pull_request, push] + +jobs: + + build_test: + name: test-${{ matrix.os }}-${{ matrix.test-framework }} + runs-on: ${{ matrix.os }} + env: + configuration: Release + artifacts: ${{ github.workspace }}/artifacts + DOTNET_NOLOGO : 1 + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + test-framework: [netcoreapp3.1, net5.0] + include: + - os: ubuntu-latest + test-framework: netcoreapp3.1 + installSnap7: true + dotnet-sdk: '3.1.x' + - os: ubuntu-latest + test-framework: net5.0 + installSnap7: true + dotnet-sdk: '5.0.x' + - os: macos-latest + test-framework: netcoreapp3.1 + installSnap7: true + dotnet-sdk: '3.1.x' + - os: macos-latest + test-framework: net5.0 + installSnap7: true + dotnet-sdk: '5.0.x' + - os: windows-latest + test-framework: netcoreapp3.1 + dotnet-sdk: '3.1.x' + - os: windows-latest + test-framework: net5.0 + dotnet-sdk: '5.0.x' + - os: windows-latest + test-framework: net452 + dotnet-sdk: '5.0.x' + fail-fast: false + + steps: + - uses: actions/checkout@v2 + + - name: Install Snap7 Linux + if: ${{ matrix.installSnap7 && matrix.os == 'ubuntu-latest' }} + run: | + sudo add-apt-repository ppa:gijzelaar/snap7 + sudo apt-get update + sudo apt-get install libsnap7-1 libsnap7-dev + + - name: Install Snap7 MacOs + if: ${{ matrix.installSnap7 && matrix.os == 'macos-latest' }} + run: | + brew install snap7 + + - name: Setup Dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ matrix.dotnet-sdk }} + + - name: Nuget Cache + uses: actions/cache@v2 + with: + path: ~/.nuget/packages + # Look to see if there is a cache hit for the corresponding requirements file + key: ${{ runner.os }}-${{ matrix.test-framework }}-nuget-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.test-framework }}-nuget + + - name: Restore + run: dotnet restore S7.Net.UnitTest + + - name: Test + run: dotnet test --no-restore --nologo --verbosity normal --logger GitHubActions --framework ${{ matrix.test-framework }} diff --git a/S7.Net.UnitTest/ConnectionCloseTest.cs b/S7.Net.UnitTest/ConnectionCloseTest.cs new file mode 100644 index 00000000..87c9ea99 --- /dev/null +++ b/S7.Net.UnitTest/ConnectionCloseTest.cs @@ -0,0 +1,181 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace S7.Net.UnitTest +{ + /// + /// Test stream which only gives 1 byte per read. + /// + class TestStreamConnectionClose : Stream + { + private readonly CancellationTokenSource _cancellationTokenSource; + + public TestStreamConnectionClose(CancellationTokenSource cancellationTokenSource) + { + _cancellationTokenSource = cancellationTokenSource; + } + public override bool CanRead => false; + + public override bool CanSeek => throw new NotImplementedException(); + + public override bool CanWrite => true; + + public override long Length => throw new NotImplementedException(); + + public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + public override void Flush() + { + throw new NotImplementedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotImplementedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotImplementedException(); + } + + public override void SetLength(long value) + { + throw new NotImplementedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _cancellationTokenSource.Cancel(); + } + } + + /// + /// These tests are intended to test functions and other stream-related special cases. + /// + [TestClass] + public class ConnectionCloseTest + { + const short TestServerPort = 31122; + const string TestServerIp = "127.0.0.1"; + + [TestMethod] + public async Task Test_CancellationDuringTransmission() + { + var plc = new Plc(CpuType.S7300, TestServerIp, TestServerPort, 0, 2); + + // Set up a shared cancellation source so we can let the stream + // initiate cancel after some data has been written to it. + var cancellationSource = new CancellationTokenSource(); + var cancellationToken = cancellationSource.Token; + + var stream = new TestStreamConnectionClose(cancellationSource); + var requestData = new byte[100]; // empty data, it does not matter what is in there + + // Set up access to private method and field + var dynMethod = plc.GetType().GetMethod("NoLockRequestTpduAsync", + BindingFlags.NonPublic | BindingFlags.Instance); + if (dynMethod == null) + { + throw new NullReferenceException("Could not find method 'NoLockRequestTpduAsync' on Plc object."); + } + var tcpClientField = plc.GetType().GetField("tcpClient", BindingFlags.NonPublic | BindingFlags.Instance); + if (tcpClientField == null) + { + throw new NullReferenceException("Could not find field 'tcpClient' on Plc object."); + } + + // Set a value to tcpClient field so we can later ensure that it has been closed. + tcpClientField.SetValue(plc, new TcpClient()); + var tcpClientValue = tcpClientField.GetValue(plc); + Assert.IsNotNull(tcpClientValue); + + try + { + var result = (Task) dynMethod.Invoke(plc, new object[] { stream, requestData, cancellationToken }); + await result; + } + catch (OperationCanceledException) + { + Console.WriteLine("Task was cancelled as expected."); + + // Ensure that the plc connection was closed since the task was cancelled + // after data has been sent through the network. We expect that the tcpClient + // object was set to NULL + var tcpClientValueAfter = tcpClientField.GetValue(plc); + Assert.IsNull(tcpClientValueAfter); + return; + } + catch (Exception e) + { + Assert.Fail($"Wrong exception type received. Expected {typeof(OperationCanceledException)}, received {e.GetType()}."); + } + + // Ensure test fails if cancellation did not occur. + Assert.Fail("Task was not cancelled as expected."); + } + + [TestMethod] + public async Task Test_CancellationBeforeTransmission() + { + var plc = new Plc(CpuType.S7300, TestServerIp, TestServerPort, 0, 2); + + // Set up a cancellation source + var cancellationSource = new CancellationTokenSource(); + var cancellationToken = cancellationSource.Token; + + var stream = new TestStreamConnectionClose(cancellationSource); + var requestData = new byte[100]; // empty data, it does not matter what is in there + + // Set up access to private method and field + var dynMethod = plc.GetType().GetMethod("NoLockRequestTpduAsync", + BindingFlags.NonPublic | BindingFlags.Instance); + if (dynMethod == null) + { + throw new NullReferenceException("Could not find method 'NoLockRequestTpduAsync' on Plc object."); + } + var tcpClientField = plc.GetType().GetField("tcpClient", BindingFlags.NonPublic | BindingFlags.Instance); + if (tcpClientField == null) + { + throw new NullReferenceException("Could not find field 'tcpClient' on Plc object."); + } + + // Set a value to tcpClient field so we can later ensure that it has been closed. + tcpClientField.SetValue(plc, new TcpClient()); + var tcpClientValue = tcpClientField.GetValue(plc); + Assert.IsNotNull(tcpClientValue); + + try + { + // cancel the task before we start transmitting data + cancellationSource.Cancel(); + var result = (Task)dynMethod.Invoke(plc, new object[] { stream, requestData, cancellationToken }); + await result; + } + catch (OperationCanceledException) + { + Console.WriteLine("Task was cancelled as expected."); + + // Ensure that the plc connection was not closed, since we cancelled the task before + // sending data through the network. We expect that the tcpClient + // object was NOT set to NULL + var tcpClientValueAfter = tcpClientField.GetValue(plc); + Assert.IsNotNull(tcpClientValueAfter); + return; + } + catch (Exception e) + { + Assert.Fail($"Wrong exception type received. Expected {typeof(OperationCanceledException)}, received {e.GetType()}."); + } + + // Ensure test fails if cancellation did not occur. + Assert.Fail("Task was not cancelled as expected."); + } + } +} diff --git a/S7.Net.UnitTest/ConnectionRequestTest.cs b/S7.Net.UnitTest/ConnectionRequestTest.cs index 440a962f..ec171252 100644 --- a/S7.Net.UnitTest/ConnectionRequestTest.cs +++ b/S7.Net.UnitTest/ConnectionRequestTest.cs @@ -9,52 +9,52 @@ public class ConnectionRequestTest [TestMethod] public void Test_ConnectionRequest_S7_200() { - CollectionAssert.AreEqual(MakeConnectionRequest(16, 0, 16, 0), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S7200, 0, 0)); + CollectionAssert.AreEqual(MakeConnectionRequest(16, 0, 16, 1), + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S7200, 0, 0))); } [TestMethod] public void Test_ConnectionRequest_S7_300() { CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 0), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S7300, 0, 0)); + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S7300, 0, 0))); CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 1), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S7300, 0, 1)); + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S7300, 0, 1))); CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 33), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S7300, 1, 1)); + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S7300, 1, 1))); } [TestMethod] public void Test_ConnectionRequest_S7_400() { CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 0), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S7400, 0, 0)); + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S7400, 0, 0))); CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 1), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S7400, 0, 1)); + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S7400, 0, 1))); CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 33), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S7400, 1, 1)); + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S7400, 1, 1))); } [TestMethod] public void Test_ConnectionRequest_S7_1200() { CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 0), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S71200, 0, 0)); + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S71200, 0, 0))); CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 1), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S71200, 0, 1)); + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S71200, 0, 1))); CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 33), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S71200, 1, 1)); + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S71200, 1, 1))); } [TestMethod] public void Test_ConnectionRequest_S7_1500() { - CollectionAssert.AreEqual(MakeConnectionRequest(0x10, 0x2, 3, 0), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S71500, 0, 0)); - CollectionAssert.AreEqual(MakeConnectionRequest(0x10, 0x2, 3, 1), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S71500, 0, 1)); - CollectionAssert.AreEqual(MakeConnectionRequest(0x10, 0x2, 3, 33), - ConnectionRequest.GetCOTPConnectionRequest(CpuType.S71500, 1, 1)); + CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 0), + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S71500, 0, 0))); + CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 1), + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S71500, 0, 1))); + CollectionAssert.AreEqual(MakeConnectionRequest(1, 0, 3, 33), + ConnectionRequest.GetCOTPConnectionRequest(TsapPair.GetDefaultTsapPair(CpuType.S71500, 1, 1))); } private static byte[] MakeConnectionRequest(byte sourceTsap1, byte sourceTsap2, byte destTsap1, byte destTsap2) @@ -63,7 +63,7 @@ private static byte[] MakeConnectionRequest(byte sourceTsap1, byte sourceTsap2, { 3, 0, 0, 22, //TPKT 17, //COTP Header Length - 224, //Connect Request + 224, //Connect Request 0, 0, //Destination Reference 0, 46, //Source Reference 0, //Flags diff --git a/S7.Net.UnitTest/ProtocolTests.cs b/S7.Net.UnitTest/ProtocolTests.cs index 2051edbc..e63fac6c 100644 --- a/S7.Net.UnitTest/ProtocolTests.cs +++ b/S7.Net.UnitTest/ProtocolTests.cs @@ -1,14 +1,10 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using S7.Net; - using System.IO; +using System.Linq; +using System.Threading; using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; using S7.Net.Protocol; -using System.Collections; namespace S7.Net.UnitTest { @@ -21,21 +17,17 @@ public class ProtocolUnitTest public async Task TPKT_Read() { var m = new MemoryStream(StringToByteArray("0300002902f0803203000000010002001400000401ff0400807710000100000103000000033f8ccccd")); - var t = TPKT.Read(m); - Assert.AreEqual(0x03, t.Version); - Assert.AreEqual(0x29, t.Length); - m.Position = 0; - t = await TPKT.ReadAsync(m, TestContext.CancellationTokenSource.Token); + var t = await TPKT.ReadAsync(m, TestContext.CancellationTokenSource.Token); Assert.AreEqual(0x03, t.Version); Assert.AreEqual(0x29, t.Length); } [TestMethod] [ExpectedException(typeof(TPKTInvalidException))] - public void TPKT_ReadShort() + public async Task TPKT_ReadShort() { var m = new MemoryStream(StringToByteArray("0300002902f0803203000000010002001400000401ff040080")); - var t = TPKT.Read(m); + var t = await TPKT.ReadAsync(m, CancellationToken.None); } @@ -48,14 +40,11 @@ public async Task TPKT_ReadShortAsync() } [TestMethod] - public void COTP_ReadTSDU() + public async Task COTP_ReadTSDU() { var expected = StringToByteArray("320700000400000800080001120411440100ff09000400000000"); var m = new MemoryStream(StringToByteArray("0300000702f0000300000702f0000300002102f080320700000400000800080001120411440100ff09000400000000")); - var t = COTP.TSDU.Read(m); - Assert.IsTrue(expected.SequenceEqual(t)); - m.Position = 0; - t = COTP.TSDU.ReadAsync(m, TestContext.CancellationTokenSource.Token).Result; + var t = await COTP.TSDU.ReadAsync(m, TestContext.CancellationTokenSource.Token); Assert.IsTrue(expected.SequenceEqual(t)); } @@ -69,14 +58,13 @@ public static byte[] StringToByteArray(string hex) [TestMethod] - public void TestResponseCode() + public async Task TestResponseCode() { var expected = StringToByteArray("320700000400000800080001120411440100ff09000400000000"); var m = new MemoryStream(StringToByteArray("0300000702f0000300000702f0000300002102f080320700000400000800080001120411440100ff09000400000000")); - var t = COTP.TSDU.Read(m); + var t = await COTP.TSDU.ReadAsync(m, CancellationToken.None); Assert.IsTrue(expected.SequenceEqual(t)); - // Test all possible byte values. Everything except 0xff should throw an exception. var testData = Enumerable.Range(0, 256).Select(i => new { StatusCode = (ReadWriteErrorCode)i, ThrowsException = i != (byte)ReadWriteErrorCode.Success }); diff --git a/S7.Net.UnitTest/S7.Net.UnitTest.csproj b/S7.Net.UnitTest/S7.Net.UnitTest.csproj index e836b623..eb63e48c 100644 --- a/S7.Net.UnitTest/S7.Net.UnitTest.csproj +++ b/S7.Net.UnitTest/S7.Net.UnitTest.csproj @@ -1,7 +1,7 @@  - net452;netcoreapp3.1 + net452;netcoreapp3.1;net5.0 true Properties\S7.Net.snk diff --git a/S7.Net.UnitTest/S7NetTestsAsync.cs b/S7.Net.UnitTest/S7NetTestsAsync.cs index cb636604..f94ddb81 100644 --- a/S7.Net.UnitTest/S7NetTestsAsync.cs +++ b/S7.Net.UnitTest/S7NetTestsAsync.cs @@ -935,20 +935,53 @@ public async Task Test_Async_WriteLargeByteArrayWithCancellation() { await plc.WriteBytesAsync(DataType.DataBlock, db, 0, data, cancellationToken); } - catch(TaskCanceledException) + catch(OperationCanceledException) { // everything is good, that is the exception we expect - Console.WriteLine("Task was cancelled as expected."); + Console.WriteLine("Operation was cancelled as expected."); return; } catch(Exception e) { - Assert.Fail($"Wrong exception type received. Expected {typeof(TaskCanceledException)}, received {e.GetType()}."); + Assert.Fail($"Wrong exception type received. Expected {typeof(OperationCanceledException)}, received {e.GetType()}."); } // Depending on how tests run, this can also just succeed without getting cancelled at all. Do nothing in this case. Console.WriteLine("Task was not cancelled as expected."); } + + /// + /// Write a large amount of data and test cancellation + /// + [TestMethod] + public async Task Test_Async_ParseDataIntoDataItemsAlignment() + { + Assert.IsTrue(plc.IsConnected, "Before executing this test, the plc must be connected. Check constructor."); + + var db = 2; + // First write a sensible S7 string capacity + await plc.WriteBytesAsync(DataType.DataBlock, db, 0, new byte[] {5, 0}); + + // Read two data items, with the first having odd number of bytes (7), + // and the second has to be aligned on a even address + var dataItems = new List + { + new DataItem + { + DataType = DataType.DataBlock, + DB = db, + VarType = VarType.S7String, + Count = 5 + }, + new DataItem + { + DataType = DataType.DataBlock, + DB = db, + VarType = VarType.Word, + } + }; + await plc.ReadMultipleVarsAsync(dataItems, CancellationToken.None); + } #endregion } } diff --git a/S7.Net.UnitTest/StreamTests.cs b/S7.Net.UnitTest/StreamTests.cs index 9cb09690..d5fe2a1e 100644 --- a/S7.Net.UnitTest/StreamTests.cs +++ b/S7.Net.UnitTest/StreamTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace S7.Net.UnitTest @@ -15,6 +16,7 @@ public TestStream1BytePerRead(byte[] data) { Data = data; } + public override bool CanRead => _position < Data.Length; public override bool CanSeek => throw new NotImplementedException(); @@ -26,21 +28,31 @@ public TestStream1BytePerRead(byte[] data) public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } public byte[] Data { get; } + int _position = 0; + public override void Flush() { throw new NotImplementedException(); } - int _position = 0; public override int Read(byte[] buffer, int offset, int count) { + throw new NotImplementedException(); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_position >= Data.Length) { - return 0; + return Task.FromResult(0); } + buffer[offset] = Data[_position]; ++_position; - return 1; + + return Task.FromResult(1); } public override long Seek(long offset, SeekOrigin origin) @@ -78,21 +90,21 @@ public async Task TPKT_ReadRestrictedStreamAsync() } [TestMethod] - public void TPKT_ReadRestrictedStream() + public async Task TPKT_ReadRestrictedStream() { var fullMessage = ProtocolUnitTest.StringToByteArray("0300002902f0803203000000010002001400000401ff0400807710000100000103000000033f8ccccd"); var m = new TestStream1BytePerRead(fullMessage); - var t = TPKT.Read(m); + var t = await TPKT.ReadAsync(m, CancellationToken.None); Assert.AreEqual(fullMessage.Length, t.Length); Assert.AreEqual(fullMessage.Last(), t.Data.Last()); } [TestMethod] - public void TPKT_ReadStreamTooShort() + public async Task TPKT_ReadStreamTooShort() { var fullMessage = ProtocolUnitTest.StringToByteArray("0300002902f0803203000000010002001400"); var m = new TestStream1BytePerRead(fullMessage); - Assert.ThrowsException(() => TPKT.Read(m)); + await Assert.ThrowsExceptionAsync(() => TPKT.ReadAsync(m, CancellationToken.None)); } } } diff --git a/S7.Net/COTP.cs b/S7.Net/COTP.cs index a7d9fd7e..3e5abbbb 100644 --- a/S7.Net/COTP.cs +++ b/S7.Net/COTP.cs @@ -50,22 +50,6 @@ public TPDU(TPKT tPKT) Data = new byte[0]; } - /// - /// Reads COTP TPDU (Transport protocol data unit) from the network stream - /// See: https://tools.ietf.org/html/rfc905 - /// - /// The socket to read from - /// COTP DPDU instance - public static TPDU Read(Stream stream) - { - var tpkt = TPKT.Read(stream); - if (tpkt.Length == 0) - { - throw new TPDUInvalidException("No protocol data received"); - } - return new TPDU(tpkt); - } - /// /// Reads COTP TPDU (Transport protocol data unit) from the network stream /// See: https://tools.ietf.org/html/rfc905 @@ -100,36 +84,6 @@ public override string ToString() /// public class TSDU { - /// - /// Reads the full COTP TSDU (Transport service data unit) - /// See: https://tools.ietf.org/html/rfc905 - /// - /// The stream to read from - /// Data in TSDU - public static byte[] Read(Stream stream) - { - var segment = TPDU.Read(stream); - - if (segment.LastDataUnit) - { - return segment.Data; - } - - // More segments are expected, prepare a buffer to store all data - var buffer = new byte[segment.Data.Length]; - Array.Copy(segment.Data, buffer, segment.Data.Length); - - while (!segment.LastDataUnit) - { - segment = TPDU.Read(stream); - var previousLength = buffer.Length; - Array.Resize(ref buffer, buffer.Length + segment.Data.Length); - Array.Copy(segment.Data, 0, buffer, previousLength, segment.Data.Length); - } - - return buffer; - } - /// /// Reads the full COTP TSDU (Transport service data unit) /// See: https://tools.ietf.org/html/rfc905 @@ -137,7 +91,7 @@ public static byte[] Read(Stream stream) /// The stream to read from /// Data in TSDU public static async Task ReadAsync(Stream stream, CancellationToken cancellationToken) - { + { var segment = await TPDU.ReadAsync(stream, cancellationToken).ConfigureAwait(false); if (segment.LastDataUnit) diff --git a/S7.Net/Enums.cs b/S7.Net/Enums.cs index 436d94bc..fc412d3e 100644 --- a/S7.Net/Enums.cs +++ b/S7.Net/Enums.cs @@ -15,6 +15,11 @@ public enum CpuType /// Logo0BA8 = 1, + /// + /// S7 200 Smart + /// + S7200Smart = 2, + /// /// S7 300 cpu type /// diff --git a/S7.Net/PLC.cs b/S7.Net/PLC.cs index 6180ad5c..35d038e0 100644 --- a/S7.Net/PLC.cs +++ b/S7.Net/PLC.cs @@ -15,16 +15,24 @@ namespace S7.Net /// public partial class Plc : IDisposable { - private readonly TaskQueue queue = new TaskQueue(); + /// + /// The default port for the S7 protocol. + /// + public const int DefaultPort = 102; + + /// + /// The default timeout (in milliseconds) used for and . + /// + public const int DefaultTimeout = 10_000; - private const int CONNECTION_TIMED_OUT_ERROR_CODE = 10060; + private readonly TaskQueue queue = new TaskQueue(); //TCP connection to device private TcpClient? tcpClient; private NetworkStream? _stream; - private int readTimeout = 0; // default no timeout - private int writeTimeout = 0; // default no timeout + private int readTimeout = DefaultTimeout; // default no timeout + private int writeTimeout = DefaultTimeout; // default no timeout /// /// IP address of the PLC @@ -36,6 +44,11 @@ public partial class Plc : IDisposable /// public int Port { get; } + /// + /// The TSAP addresses used during the connection request. + /// + public TsapPair TsapPair { get; set; } + /// /// CPU type of the PLC /// @@ -108,50 +121,67 @@ public int WriteTimeout /// /// CpuType of the PLC (select from the enum) /// Ip address of the PLC - /// Port address of the PLC, default 102 + /// rack of the PLC, usually it's 0, but check in the hardware configuration of Step7 or TIA portal + /// slot of the CPU of the PLC, usually it's 2 for S7300-S7400, 0 for S7-1200 and S7-1500. + /// If you use an external ethernet card, this must be set accordingly. + public Plc(CpuType cpu, string ip, Int16 rack, Int16 slot) + : this(cpu, ip, DefaultPort, rack, slot) + { + } + + /// + /// Creates a PLC object with all the parameters needed for connections. + /// For S7-1200 and S7-1500, the default is rack = 0 and slot = 0. + /// You need slot > 0 if you are connecting to external ethernet card (CP). + /// For S7-300 and S7-400 the default is rack = 0 and slot = 2. + /// + /// CpuType of the PLC (select from the enum) + /// Ip address of the PLC + /// Port number used for the connection, default 102. /// rack of the PLC, usually it's 0, but check in the hardware configuration of Step7 or TIA portal /// slot of the CPU of the PLC, usually it's 2 for S7300-S7400, 0 for S7-1200 and S7-1500. /// If you use an external ethernet card, this must be set accordingly. public Plc(CpuType cpu, string ip, int port, Int16 rack, Int16 slot) + : this(ip, port, TsapPair.GetDefaultTsapPair(cpu, rack, slot)) { if (!Enum.IsDefined(typeof(CpuType), cpu)) - throw new ArgumentException($"The value of argument '{nameof(cpu)}' ({cpu}) is invalid for Enum type '{typeof(CpuType).Name}'.", nameof(cpu)); - - if (string.IsNullOrEmpty(ip)) - throw new ArgumentException("IP address must valid.", nameof(ip)); + throw new ArgumentException( + $"The value of argument '{nameof(cpu)}' ({cpu}) is invalid for Enum type '{typeof(CpuType).Name}'.", + nameof(cpu)); CPU = cpu; - IP = ip; - Port = port; Rack = rack; Slot = slot; - MaxPDUSize = 240; } + /// /// Creates a PLC object with all the parameters needed for connections. /// For S7-1200 and S7-1500, the default is rack = 0 and slot = 0. /// You need slot > 0 if you are connecting to external ethernet card (CP). /// For S7-300 and S7-400 the default is rack = 0 and slot = 2. /// - /// CpuType of the PLC (select from the enum) /// Ip address of the PLC - /// rack of the PLC, usually it's 0, but check in the hardware configuration of Step7 or TIA portal - /// slot of the CPU of the PLC, usually it's 2 for S7300-S7400, 0 for S7-1200 and S7-1500. - /// If you use an external ethernet card, this must be set accordingly. - public Plc(CpuType cpu, string ip, Int16 rack, Int16 slot) + /// The TSAP addresses used for the connection request. + public Plc(string ip, TsapPair tsapPair) : this(ip, DefaultPort, tsapPair) { - if (!Enum.IsDefined(typeof(CpuType), cpu)) - throw new ArgumentException($"The value of argument '{nameof(cpu)}' ({cpu}) is invalid for Enum type '{typeof(CpuType).Name}'.", nameof(cpu)); + } + /// + /// Creates a PLC object with all the parameters needed for connections. Use this constructor + /// if you want to manually override the TSAP addresses used during the connection request. + /// + /// Ip address of the PLC + /// Port number used for the connection, default 102. + /// The TSAP addresses used for the connection request. + public Plc(string ip, int port, TsapPair tsapPair) + { if (string.IsNullOrEmpty(ip)) throw new ArgumentException("IP address must valid.", nameof(ip)); - CPU = cpu; IP = ip; - Port = 102; - Rack = rack; - Slot = slot; + Port = port; MaxPDUSize = 240; + TsapPair = tsapPair; } /// @@ -162,6 +192,7 @@ public void Close() if (tcpClient != null) { if (tcpClient.Connected) tcpClient.Close(); + tcpClient = null; // Can not reuse TcpClient once connection gets closed. } } diff --git a/S7.Net/PLCHelpers.cs b/S7.Net/PLCHelpers.cs index 5b2d77fa..77159225 100644 --- a/S7.Net/PLCHelpers.cs +++ b/S7.Net/PLCHelpers.cs @@ -242,8 +242,8 @@ private void ParseDataIntoDataItems(byte[] s7data, List dataItems) // next Item offset += byteCnt; - // Fill byte in response when bytecount is odd - if (dataItem.Count % 2 != 0 && (dataItem.VarType == VarType.Byte || dataItem.VarType == VarType.Bit)) + // Always align to even offset + if (offset % 2 != 0) offset++; } } diff --git a/S7.Net/PlcAsynchronous.cs b/S7.Net/PlcAsynchronous.cs index c7a5868e..a58f6293 100644 --- a/S7.Net/PlcAsynchronous.cs +++ b/S7.Net/PlcAsynchronous.cs @@ -37,7 +37,7 @@ await queue.Enqueue(async () => return default(object); }).ConfigureAwait(false); } - catch(Exception) + catch (Exception) { stream.Dispose(); throw; @@ -60,7 +60,7 @@ private async Task EstablishConnection(Stream stream, CancellationToken cancella private async Task RequestConnection(Stream stream, CancellationToken cancellationToken) { - var requestData = ConnectionRequest.GetCOTPConnectionRequest(CPU, Rack, Slot); + var requestData = ConnectionRequest.GetCOTPConnectionRequest(TsapPair); var response = await NoLockRequestTpduAsync(stream, requestData, cancellationToken).ConfigureAwait(false); if (response.PDUType != COTP.PduType.ConnectionConfirmed) @@ -438,7 +438,7 @@ public async Task WriteClassAsync(object classValue, int db, int startByteAdr = private async Task ReadBytesWithSingleRequestAsync(DataType dataType, int db, int startByteAdr, byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - var dataToSend = BuildReadRequestPackage(new [] { new DataItemAddress(dataType, db, startByteAdr, count)}); + var dataToSend = BuildReadRequestPackage(new[] { new DataItemAddress(dataType, db, startByteAdr, count) }); var s7data = await RequestTsduAsync(dataToSend, cancellationToken); AssertReadResponse(s7data, count); @@ -521,22 +521,46 @@ private Task RequestTsduAsync(byte[] requestData, int offset, int length NoLockRequestTsduAsync(stream, requestData, offset, length, cancellationToken)); } - private static async Task NoLockRequestTpduAsync(Stream stream, byte[] requestData, + private async Task NoLockRequestTpduAsync(Stream stream, byte[] requestData, CancellationToken cancellationToken = default) { - await stream.WriteAsync(requestData, 0, requestData.Length, cancellationToken).ConfigureAwait(false); - var response = await COTP.TPDU.ReadAsync(stream, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + try + { + using var closeOnCancellation = cancellationToken.Register(Close); + await stream.WriteAsync(requestData, 0, requestData.Length, cancellationToken).ConfigureAwait(false); + return await COTP.TPDU.ReadAsync(stream, cancellationToken).ConfigureAwait(false); + } + catch (Exception exc) + { + if (exc is TPDUInvalidException || exc is TPKTInvalidException) + { + Close(); + } - return response; + throw; + } } - private static async Task NoLockRequestTsduAsync(Stream stream, byte[] requestData, int offset, int length, + private async Task NoLockRequestTsduAsync(Stream stream, byte[] requestData, int offset, int length, CancellationToken cancellationToken = default) { - await stream.WriteAsync(requestData, offset, length, cancellationToken).ConfigureAwait(false); - var response = await COTP.TSDU.ReadAsync(stream, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + try + { + using var closeOnCancellation = cancellationToken.Register(Close); + await stream.WriteAsync(requestData, offset, length, cancellationToken).ConfigureAwait(false); + return await COTP.TSDU.ReadAsync(stream, cancellationToken).ConfigureAwait(false); + } + catch (Exception exc) + { + if (exc is TPDUInvalidException || exc is TPKTInvalidException) + { + Close(); + } - return response; + throw; + } } } } diff --git a/S7.Net/Protocol/ConnectionRequest.cs b/S7.Net/Protocol/ConnectionRequest.cs index 64e2b5d4..9dbd3967 100644 --- a/S7.Net/Protocol/ConnectionRequest.cs +++ b/S7.Net/Protocol/ConnectionRequest.cs @@ -1,68 +1,27 @@ -using System; - -namespace S7.Net.Protocol +namespace S7.Net.Protocol { internal static class ConnectionRequest { - public static byte[] GetCOTPConnectionRequest(CpuType cpu, Int16 rack, Int16 slot) + public static byte[] GetCOTPConnectionRequest(TsapPair tsapPair) { byte[] bSend1 = { 3, 0, 0, 22, //TPKT 17, //COTP Header Length - 224, //Connect Request + 224, //Connect Request 0, 0, //Destination Reference 0, 46, //Source Reference 0, //Flags 193, //Parameter Code (src-tasp) 2, //Parameter Length - 1, 0, //Source TASP + tsapPair.Local.FirstByte, tsapPair.Local.SecondByte, //Source TASP 194, //Parameter Code (dst-tasp) 2, //Parameter Length - 3, 0, //Destination TASP + tsapPair.Remote.FirstByte, tsapPair.Remote.SecondByte, //Destination TASP 192, //Parameter Code (tpdu-size) 1, //Parameter Length 10 //TPDU Size (2^10 = 1024) }; - switch (cpu) - { - case CpuType.S7200: - //S7200: Chr(193) & Chr(2) & Chr(16) & Chr(0) 'Eigener Tsap - bSend1[13] = 0x10; - bSend1[14] = 0x00; - //S7200: Chr(194) & Chr(2) & Chr(16) & Chr(0) 'Fremder Tsap - bSend1[17] = 0x10; - bSend1[18] = 0x00; - break; - case CpuType.Logo0BA8: - // These values are taken from NodeS7, it's not verified if these are - // exact requirements to connect to the Logo0BA8. - bSend1[13] = 0x01; - bSend1[14] = 0x00; - bSend1[17] = 0x01; - bSend1[18] = 0x02; - break; - case CpuType.S71200: - case CpuType.S7300: - case CpuType.S7400: - //S7300: Chr(193) & Chr(2) & Chr(1) & Chr(0) 'Eigener Tsap - bSend1[13] = 0x01; - bSend1[14] = 0x00; - //S7300: Chr(194) & Chr(2) & Chr(3) & Chr(2) 'Fremder Tsap - bSend1[17] = 0x03; - bSend1[18] = (byte) ((rack << 5) | (int) slot); - break; - case CpuType.S71500: - // Eigener Tsap - bSend1[13] = 0x10; - bSend1[14] = 0x02; - // Fredmer Tsap - bSend1[17] = 0x03; - bSend1[18] = (byte) ((rack << 5) | (int) slot); - break; - default: - throw new Exception("Wrong CPU Type Secified"); - } return bSend1; } } diff --git a/S7.Net/Protocol/Tsap.cs b/S7.Net/Protocol/Tsap.cs new file mode 100644 index 00000000..dc9d46c5 --- /dev/null +++ b/S7.Net/Protocol/Tsap.cs @@ -0,0 +1,31 @@ +namespace S7.Net.Protocol +{ + /// + /// Provides a representation of the Transport Service Access Point, or TSAP in short. TSAP's are used + /// to specify a client and server address. For most PLC types a default TSAP is available that allows + /// connection from any IP and can be calculated using the rack and slot numbers. + /// + public struct Tsap + { + /// + /// First byte of the TSAP. + /// + public byte FirstByte { get; set; } + + /// + /// Second byte of the TSAP. + /// + public byte SecondByte { get; set; } + + /// + /// Initializes a new instance of the class using the specified values. + /// + /// The first byte of the TSAP. + /// The second byte of the TSAP. + public Tsap(byte firstByte, byte secondByte) + { + FirstByte = firstByte; + SecondByte = secondByte; + } + } +} \ No newline at end of file diff --git a/S7.Net/Protocol/TsapPair.cs b/S7.Net/Protocol/TsapPair.cs new file mode 100644 index 00000000..e54fc328 --- /dev/null +++ b/S7.Net/Protocol/TsapPair.cs @@ -0,0 +1,96 @@ +using System; + +namespace S7.Net.Protocol +{ + /// + /// Implements a pair of TSAP addresses used to connect to a PLC. + /// + public class TsapPair + { + /// + /// The local . + /// + public Tsap Local { get; set; } + + /// + /// The remote + /// + public Tsap Remote { get; set; } + + /// + /// Initializes a new instance of the class using the specified local and + /// remote TSAP. + /// + /// The local TSAP. + /// The remote TSAP. + public TsapPair(Tsap local, Tsap remote) + { + Local = local; + Remote = remote; + } + + /// + /// Builds a that can be used to connect to a PLC using the default connection + /// addresses. + /// + /// + /// The remote TSAP is constructed using new Tsap(0x03, (byte) ((rack << 5) | slot)). + /// + /// The CPU type of the PLC. + /// The rack of the PLC's network card. + /// The slot of the PLC's network card. + /// A TSAP pair that matches the given parameters. + /// The is invalid. + /// + /// -or- + /// + /// The parameter is less than 0. + /// + /// -or- + /// + /// The parameter is greater than 15. + /// + /// -or- + /// + /// The parameter is less than 0. + /// + /// -or- + /// + /// The parameter is greater than 15. + public static TsapPair GetDefaultTsapPair(CpuType cpuType, int rack, int slot) + { + if (rack < 0) throw InvalidRackOrSlot(rack, nameof(rack), "minimum", 0); + if (rack > 0x0F) throw InvalidRackOrSlot(rack, nameof(rack), "maximum", 0x0F); + + if (slot < 0) throw InvalidRackOrSlot(slot, nameof(slot), "minimum", 0); + if (slot > 0x0F) throw InvalidRackOrSlot(slot, nameof(slot), "maximum", 0x0F); + + switch (cpuType) + { + case CpuType.S7200: + return new TsapPair(new Tsap(0x10, 0x00), new Tsap(0x10, 0x01)); + case CpuType.Logo0BA8: + // The actual values are probably on a per-project basis + return new TsapPair(new Tsap(0x01, 0x00), new Tsap(0x01, 0x02)); + case CpuType.S7200Smart: + case CpuType.S71200: + case CpuType.S71500: + case CpuType.S7300: + case CpuType.S7400: + // Testing with S7 1500 shows only the remote TSAP needs to match. This might differ for other + // PLC types. + return new TsapPair(new Tsap(0x01, 0x00), new Tsap(0x03, (byte) ((rack << 5) | slot))); + default: + throw new ArgumentOutOfRangeException(nameof(cpuType), "Invalid CPU Type specified"); + } + } + + private static ArgumentOutOfRangeException InvalidRackOrSlot(int value, string name, string extrema, + int extremaValue) + { + return new ArgumentOutOfRangeException(name, + $"Invalid {name} value specified (decimal: {value}, hexadecimal: {value:X}), {extrema} value " + + $"is {extremaValue} (decimal) or {extremaValue:X} (hexadecimal)."); + } + } +} \ No newline at end of file diff --git a/S7.Net/TPKT.cs b/S7.Net/TPKT.cs index e3d26f71..a311dcec 100644 --- a/S7.Net/TPKT.cs +++ b/S7.Net/TPKT.cs @@ -25,34 +25,6 @@ private TPKT(byte version, byte reserved1, int length, byte[] data) Data = data; } - /// - /// Reads a TPKT from the socket - /// - /// The stream to read from - /// TPKT Instance - public static TPKT Read(Stream stream) - { - var buf = new byte[4]; - int len = stream.ReadExact(buf, 0, 4); - if (len < 4) throw new TPKTInvalidException($"TPKT header is incomplete / invalid. Received Bytes: {len} expected: {buf.Length}"); - var version = buf[0]; - var reserved1 = buf[1]; - var length = buf[2] * 256 + buf[3]; //BigEndian - - var data = new byte[length - 4]; - len = stream.ReadExact(data, 0, data.Length); - if (len < data.Length) - throw new TPKTInvalidException($"TPKT payload is incomplete / invalid. Received Bytes: {len} expected: {data.Length}"); - - return new TPKT - ( - version: version, - reserved1: reserved1, - length: length, - data: data - ); - } - /// /// Reads a TPKT from the socket Async /// diff --git a/S7.sln b/S7.sln index 2ae0e521..dbfaf5cb 100644 --- a/S7.sln +++ b/S7.sln @@ -9,6 +9,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject appveyor.yml = appveyor.yml README.md = README.md + .github\workflows\test.yml = .github\workflows\test.yml EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "S7.Net.UnitTest", "S7.Net.UnitTest\S7.Net.UnitTest.csproj", "{303CCED6-9ABC-4899-A509-743341AAA804}"