From e277cf6e6c4048b161a810d28204bf1431c040bf Mon Sep 17 00:00:00 2001 From: ArgusMagnus Date: Tue, 30 May 2023 10:47:38 +0200 Subject: [PATCH] add Read/WriteBytes(Async) overloads accepting Span/Memory for .NET5 or greater --- S7.Net.UnitTest/S7NetTestsAsync.cs | 99 +++++++++++++++++++++ S7.Net.UnitTest/S7NetTestsSync.cs | 59 ++++++++++++- S7.Net/PlcAsynchronous.cs | 100 +++++++++++++++++++++ S7.Net/PlcSynchronous.cs | 134 +++++++++++++++++++++++++++++ 4 files changed, 391 insertions(+), 1 deletion(-) diff --git a/S7.Net.UnitTest/S7NetTestsAsync.cs b/S7.Net.UnitTest/S7NetTestsAsync.cs index 731e802f..5d598824 100644 --- a/S7.Net.UnitTest/S7NetTestsAsync.cs +++ b/S7.Net.UnitTest/S7NetTestsAsync.cs @@ -9,6 +9,11 @@ using System.Threading; using System.Security.Cryptography; + +#if NET5_0_OR_GREATER +using System.Buffers; +#endif + #endregion /** @@ -139,6 +144,33 @@ public async Task Test_Async_WriteLargeByteArray() CollectionAssert.AreEqual(data, readData); } +#if NET5_0_OR_GREATER + + /// + /// Write/Read a large amount of data to test PDU max + /// + [TestMethod] + public async Task Test_Async_WriteLargeByteArrayWithMemory() + { + Assert.IsTrue(plc.IsConnected, "Before executing this test, the plc must be connected. Check constructor."); + + var randomEngine = new Random(); + using var dataOwner = MemoryPool.Shared.Rent(8192); + var data = dataOwner.Memory.Slice(0, 8192); + var db = 2; + randomEngine.NextBytes(data.Span); + + await plc.WriteBytesAsync(DataType.DataBlock, db, 0, data); + + using var readDataOwner = MemoryPool.Shared.Rent(data.Length); + var readData = readDataOwner.Memory.Slice(0, data.Length); + await plc.ReadBytesAsync(readData, DataType.DataBlock, db, 0); + + CollectionAssert.AreEqual(data.ToArray(), readData.ToArray()); + } + +#endif + /// /// Read/Write a class that has the same properties of a DB with the same field in the same order /// @@ -933,6 +965,31 @@ public async Task Test_Async_ReadWriteBytesMany() } } +#if NET5_0_OR_GREATER + + [TestMethod] + public async Task Test_Async_ReadWriteBytesManyWithMemory() + { + Assert.IsTrue(plc.IsConnected, "Before executing this test, the plc must be connected. Check constructor."); + + using var data = MemoryPool.Shared.Rent(2000); + for (int i = 0; i < data.Memory.Length; i++) + data.Memory.Span[i] = (byte)(i % 256); + + await plc.WriteBytesAsync(DataType.DataBlock, 2, 0, data.Memory); + + using var readData = MemoryPool.Shared.Rent(data.Memory.Length); + + await plc.ReadBytesAsync(readData.Memory.Slice(0, data.Memory.Length), DataType.DataBlock, 2, 0); + + for (int x = 0; x < data.Memory.Length; x++) + { + Assert.AreEqual(x % 256, readData.Memory.Span[x], string.Format("Bit {0} failed", x)); + } + } + +#endif + /// /// Write a large amount of data and test cancellation /// @@ -969,6 +1026,47 @@ public async Task Test_Async_WriteLargeByteArrayWithCancellation() Console.WriteLine("Task was not cancelled as expected."); } +#if NET5_0_OR_GREATER + + /// + /// Write a large amount of data and test cancellation + /// + [TestMethod] + public async Task Test_Async_WriteLargeByteArrayWithCancellationWithMemory() + { + Assert.IsTrue(plc.IsConnected, "Before executing this test, the plc must be connected. Check constructor."); + + var cancellationSource = new CancellationTokenSource(); + var cancellationToken = cancellationSource.Token; + + using var dataOwner = MemoryPool.Shared.Rent(8192); + var data = dataOwner.Memory.Slice(0, 8192); + var randomEngine = new Random(); + var db = 2; + randomEngine.NextBytes(data.Span); + + cancellationSource.CancelAfter(TimeSpan.FromMilliseconds(5)); + try + { + await plc.WriteBytesAsync(DataType.DataBlock, db, 0, data, cancellationToken); + } + catch (OperationCanceledException) + { + // everything is good, that is the exception we expect + Console.WriteLine("Operation was cancelled as expected."); + return; + } + catch (Exception e) + { + 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."); + } + +#endif + /// /// Write a large amount of data and test cancellation /// @@ -1001,6 +1099,7 @@ public async Task Test_Async_ParseDataIntoDataItemsAlignment() }; await plc.ReadMultipleVarsAsync(dataItems, CancellationToken.None); } + #endregion } } diff --git a/S7.Net.UnitTest/S7NetTestsSync.cs b/S7.Net.UnitTest/S7NetTestsSync.cs index 76317c0f..85e22461 100644 --- a/S7.Net.UnitTest/S7NetTestsSync.cs +++ b/S7.Net.UnitTest/S7NetTestsSync.cs @@ -7,6 +7,10 @@ using S7.UnitTest.Helpers; using System.Security.Cryptography; +#if NET5_0_OR_GREATER +using System.Buffers; +#endif + #endregion /** @@ -778,6 +782,33 @@ public void T33_WriteLargeByteArray() CollectionAssert.AreEqual(data, readData); } +#if NET5_0_OR_GREATER + + /// + /// Write/Read a large amount of data to test PDU max + /// + [TestMethod] + public void T33_WriteLargeByteArrayWithSpan() + { + Assert.IsTrue(plc.IsConnected, "Before executing this test, the plc must be connected. Check constructor."); + + var randomEngine = new Random(); + using var dataOwner = MemoryPool.Shared.Rent(8192); + var data = dataOwner.Memory.Span.Slice(0, 8192); + var db = 2; + randomEngine.NextBytes(data); + + plc.WriteBytes(DataType.DataBlock, db, 0, data); + + using var readDataOwner = MemoryPool.Shared.Rent(data.Length); + var readData = readDataOwner.Memory.Span.Slice(0, data.Length); + plc.ReadBytes(readData, DataType.DataBlock, db, 0); + + CollectionAssert.AreEqual(data.ToArray(), readData.ToArray()); + } + +#endif + [TestMethod, ExpectedException(typeof(PlcException))] public void T18_ReadStructThrowsIfPlcIsNotConnected() { @@ -1006,6 +1037,32 @@ public void T27_ReadWriteBytesMany() } } +#if NET5_0_OR_GREATER + + [TestMethod] + public void T27_ReadWriteBytesManyWithSpan() + { + Assert.IsTrue(plc.IsConnected, "Before executing this test, the plc must be connected. Check constructor."); + + using var dataOwner = MemoryPool.Shared.Rent(2000); + var data = dataOwner.Memory.Span; + for (int i = 0; i < data.Length; i++) + data[i] = (byte)(i % 256); + + plc.WriteBytes(DataType.DataBlock, 2, 0, data); + + using var readDataOwner = MemoryPool.Shared.Rent(data.Length); + var readData = readDataOwner.Memory.Span.Slice(0, data.Length); + plc.ReadBytes(readData, DataType.DataBlock, 2, 0); + + for (int x = 0; x < data.Length; x++) + { + Assert.AreEqual(x % 256, readData[x], $"Mismatch at offset {x}, expected {x % 256}, actual {readData[x]}."); + } + } + +#endif + [TestMethod] public void T28_ReadClass_DoesntCrash_When_ReadingLessThan1Byte() { @@ -1060,7 +1117,7 @@ public void T33_ReadWriteDateTimeLong() Assert.AreEqual(test_value, test_value2, "Compare DateTimeLong Write/Read"); } - #endregion +#endregion #region Private methods diff --git a/S7.Net/PlcAsynchronous.cs b/S7.Net/PlcAsynchronous.cs index f75cf233..c4aeb90e 100644 --- a/S7.Net/PlcAsynchronous.cs +++ b/S7.Net/PlcAsynchronous.cs @@ -122,6 +122,34 @@ public async Task ReadBytesAsync(DataType dataType, int db, int startByt return resultBytes; } +#if NET5_0_OR_GREATER + + /// + /// Reads a number of bytes from a DB starting from a specified index. This handles more than 200 bytes with multiple requests. + /// If the read was not successful, check LastErrorCode or LastErrorString. + /// + /// Buffer to receive the read bytes. The determines the number of bytes to read. + /// Data type of the memory area, can be DB, Timer, Counter, Merker(Memory), Input, Output. + /// Address of the memory area (if you want to read DB1, this is set to 1). This must be set also for other memory area types: counters, timers,etc. + /// Start byte address. If you want to read DB1.DBW200, this is 200. + /// The token to monitor for cancellation requests. The default value is None. + /// Please note that cancellation is advisory/cooperative and will not lead to immediate cancellation in all cases. + /// Returns the bytes in an array + public async Task ReadBytesAsync(Memory buffer, DataType dataType, int db, int startByteAdr, CancellationToken cancellationToken = default) + { + int index = 0; + while (buffer.Length > 0) + { + //This works up to MaxPDUSize-1 on SNAP7. But not MaxPDUSize-0. + var maxToRead = Math.Min(buffer.Length, MaxPDUSize - 18); + await ReadBytesWithSingleRequestAsync(dataType, db, startByteAdr + index, buffer.Slice(0, maxToRead), cancellationToken).ConfigureAwait(false); + buffer = buffer.Slice(maxToRead); + index += maxToRead; + } + } + +#endif + /// /// Read and decode a certain number of bytes of the "VarType" provided. /// This can be used to read multiple consecutive variables of the same type (Word, DWord, Int, etc). @@ -320,6 +348,33 @@ public async Task WriteBytesAsync(DataType dataType, int db, int startByteAdr, b } } +#if NET5_0_OR_GREATER + + /// + /// Write a number of bytes from a DB starting from a specified index. This handles more than 200 bytes with multiple requests. + /// If the write was not successful, check LastErrorCode or LastErrorString. + /// + /// Data type of the memory area, can be DB, Timer, Counter, Merker(Memory), Input, Output. + /// Address of the memory area (if you want to read DB1, this is set to 1). This must be set also for other memory area types: counters, timers,etc. + /// Start byte address. If you want to write DB1.DBW200, this is 200. + /// Bytes to write. If more than 200, multiple requests will be made. + /// The token to monitor for cancellation requests. The default value is None. + /// Please note that cancellation is advisory/cooperative and will not lead to immediate cancellation in all cases. + /// A task that represents the asynchronous write operation. + public async Task WriteBytesAsync(DataType dataType, int db, int startByteAdr, ReadOnlyMemory value, CancellationToken cancellationToken = default) + { + int localIndex = 0; + while (value.Length > 0) + { + var maxToWrite = (int)Math.Min(value.Length, MaxPDUSize - 35); + await WriteBytesWithASingleRequestAsync(dataType, db, startByteAdr + localIndex, value.Slice(0, maxToWrite), cancellationToken).ConfigureAwait(false); + value = value.Slice(maxToWrite); + localIndex += maxToWrite; + } + } + +#endif + /// /// Write a single bit from a DB with the specified index. /// @@ -451,6 +506,20 @@ private async Task ReadBytesWithSingleRequestAsync(DataType dataType, int db, in Array.Copy(s7data, 18, buffer, offset, count); } +#if NET5_0_OR_GREATER + + private async Task ReadBytesWithSingleRequestAsync(DataType dataType, int db, int startByteAdr, Memory buffer, CancellationToken cancellationToken) + { + var dataToSend = BuildReadRequestPackage(new[] { new DataItemAddress(dataType, db, startByteAdr, buffer.Length) }); + + var s7data = await RequestTsduAsync(dataToSend, cancellationToken); + AssertReadResponse(s7data, buffer.Length); + + s7data.AsSpan(18, buffer.Length).CopyTo(buffer.Span); + } + +#endif + /// /// Write DataItem(s) to the PLC. Throws an exception if the response is invalid /// or when the PLC reports errors for item(s) written. @@ -496,6 +565,37 @@ private async Task WriteBytesWithASingleRequestAsync(DataType dataType, int db, } } +#if NET5_0_OR_GREATER + + /// + /// Writes up to 200 bytes to the PLC. You must specify the memory area type, memory are address, byte start address and bytes count. + /// + /// Data type of the memory area, can be DB, Timer, Counter, Merker(Memory), Input, Output. + /// Address of the memory area (if you want to read DB1, this is set to 1). This must be set also for other memory area types: counters, timers,etc. + /// Start byte address. If you want to read DB1.DBW200, this is 200. + /// Bytes to write. The lenght of this parameter can't be higher than 200. If you need more, use recursion. + /// A task that represents the asynchronous write operation. + private async Task WriteBytesWithASingleRequestAsync(DataType dataType, int db, int startByteAdr, ReadOnlyMemory value, CancellationToken cancellationToken) + { + try + { + var dataToSend = BuildWriteBytesPackage(dataType, db, startByteAdr, value.Span); + var s7data = await RequestTsduAsync(dataToSend, cancellationToken).ConfigureAwait(false); + + ValidateResponseCode((ReadWriteErrorCode)s7data[14]); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exc) + { + throw new PlcException(ErrorCode.WriteData, exc); + } + } + +#endif + private async Task WriteBitWithASingleRequestAsync(DataType dataType, int db, int startByteAdr, int bitAdr, bool bitValue, CancellationToken cancellationToken) { try diff --git a/S7.Net/PlcSynchronous.cs b/S7.Net/PlcSynchronous.cs index 80e47381..0b035baa 100644 --- a/S7.Net/PlcSynchronous.cs +++ b/S7.Net/PlcSynchronous.cs @@ -51,6 +51,32 @@ public byte[] ReadBytes(DataType dataType, int db, int startByteAdr, int count) return result; } +#if NET5_0_OR_GREATER + + /// + /// Reads a number of bytes from a DB starting from a specified index. This handles more than 200 bytes with multiple requests. + /// If the read was not successful, check LastErrorCode or LastErrorString. + /// + /// Buffer to receive the read bytes. The determines the number of bytes to read. + /// Data type of the memory area, can be DB, Timer, Counter, Merker(Memory), Input, Output. + /// Address of the memory area (if you want to read DB1, this is set to 1). This must be set also for other memory area types: counters, timers,etc. + /// Start byte address. If you want to read DB1.DBW200, this is 200. + /// Returns the bytes in an array + public void ReadBytes(Span buffer, DataType dataType, int db, int startByteAdr) + { + int index = 0; + while (buffer.Length > 0) + { + //This works up to MaxPDUSize-1 on SNAP7. But not MaxPDUSize-0. + var maxToRead = Math.Min(buffer.Length, MaxPDUSize - 18); + ReadBytesWithSingleRequest(dataType, db, startByteAdr + index, buffer.Slice(0, maxToRead)); + buffer = buffer.Slice(maxToRead); + index += maxToRead; + } + } + +#endif + /// /// Read and decode a certain number of bytes of the "VarType" provided. /// This can be used to read multiple consecutive variables of the same type (Word, DWord, Int, etc). @@ -193,6 +219,33 @@ public void WriteBytes(DataType dataType, int db, int startByteAdr, byte[] value } } +#if NET5_0_OR_GREATER + + /// + /// Write a number of bytes from a DB starting from a specified index. This handles more than 200 bytes with multiple requests. + /// If the write was not successful, check LastErrorCode or LastErrorString. + /// + /// Data type of the memory area, can be DB, Timer, Counter, Merker(Memory), Input, Output. + /// Address of the memory area (if you want to read DB1, this is set to 1). This must be set also for other memory area types: counters, timers,etc. + /// Start byte address. If you want to write DB1.DBW200, this is 200. + /// Bytes to write. If more than 200, multiple requests will be made. + public void WriteBytes(DataType dataType, int db, int startByteAdr, ReadOnlySpan value) + { + int localIndex = 0; + while (value.Length > 0) + { + //TODO: Figure out how to use MaxPDUSize here + //Snap7 seems to choke on PDU sizes above 256 even if snap7 + //replies with bigger PDU size in connection setup. + var maxToWrite = Math.Min(value.Length, MaxPDUSize - 28);//TODO tested only when the MaxPDUSize is 480 + WriteBytesWithASingleRequest(dataType, db, startByteAdr + localIndex, value.Slice(0, maxToWrite)); + value = value.Slice(maxToWrite); + localIndex += maxToWrite; + } + } + +#endif + /// /// Write a single bit from a DB with the specified index. /// @@ -317,6 +370,33 @@ private void ReadBytesWithSingleRequest(DataType dataType, int db, int startByte } } +#if NET5_0_OR_GREATER + + private void ReadBytesWithSingleRequest(DataType dataType, int db, int startByteAdr, Span buffer) + { + try + { + // first create the header + int packageSize = 19 + 12; // 19 header + 12 for 1 request + var package = new System.IO.MemoryStream(packageSize); + BuildHeaderPackage(package); + // package.Add(0x02); // datenart + BuildReadDataRequestPackage(package, dataType, db, startByteAdr, buffer.Length); + + var dataToSend = package.ToArray(); + var s7data = RequestTsdu(dataToSend); + AssertReadResponse(s7data, buffer.Length); + + s7data.AsSpan(18, buffer.Length).CopyTo(buffer); + } + catch (Exception exc) + { + throw new PlcException(ErrorCode.ReadData, exc); + } + } + +#endif + /// /// Write DataItem(s) to the PLC. Throws an exception if the response is invalid /// or when the PLC reports errors for item(s) written. @@ -349,6 +429,25 @@ private void WriteBytesWithASingleRequest(DataType dataType, int db, int startBy } } +#if NET5_0_OR_GREATER + + private void WriteBytesWithASingleRequest(DataType dataType, int db, int startByteAdr, ReadOnlySpan value) + { + try + { + var dataToSend = BuildWriteBytesPackage(dataType, db, startByteAdr, value); + var s7data = RequestTsdu(dataToSend); + + ValidateResponseCode((ReadWriteErrorCode)s7data[14]); + } + catch (Exception exc) + { + throw new PlcException(ErrorCode.WriteData, exc); + } + } + +#endif + private byte[] BuildWriteBytesPackage(DataType dataType, int db, int startByteAdr, byte[] value, int dataOffset, int count) { int varCount = count; @@ -380,6 +479,41 @@ private byte[] BuildWriteBytesPackage(DataType dataType, int db, int startByteAd return package.ToArray(); } +#if NET5_0_OR_GREATER + + private byte[] BuildWriteBytesPackage(DataType dataType, int db, int startByteAdr, ReadOnlySpan value) + { + int varCount = value.Length; + // first create the header + int packageSize = 35 + varCount; + var package = new MemoryStream(new byte[packageSize]); + + package.WriteByte(3); + package.WriteByte(0); + //complete package size + package.WriteByteArray(Int.ToByteArray((short)packageSize)); + package.WriteByteArray(new byte[] { 2, 0xf0, 0x80, 0x32, 1, 0, 0 }); + package.WriteByteArray(Word.ToByteArray((ushort)(varCount - 1))); + package.WriteByteArray(new byte[] { 0, 0x0e }); + package.WriteByteArray(Word.ToByteArray((ushort)(varCount + 4))); + package.WriteByteArray(new byte[] { 0x05, 0x01, 0x12, 0x0a, 0x10, 0x02 }); + package.WriteByteArray(Word.ToByteArray((ushort)varCount)); + package.WriteByteArray(Word.ToByteArray((ushort)(db))); + package.WriteByte((byte)dataType); + var overflow = (int)(startByteAdr * 8 / 0xffffU); // handles words with address bigger than 8191 + package.WriteByte((byte)overflow); + package.WriteByteArray(Word.ToByteArray((ushort)(startByteAdr * 8))); + package.WriteByteArray(new byte[] { 0, 4 }); + package.WriteByteArray(Word.ToByteArray((ushort)(varCount * 8))); + + // now join the header and the data + package.Write(value); + + return package.ToArray(); + } + +#endif + private byte[] BuildWriteBitPackage(DataType dataType, int db, int startByteAdr, bool bitValue, int bitAdr) { var value = new[] { bitValue ? (byte)1 : (byte)0 };