mycroes/Sally7

Server side of S7 for emulation purpose

Opened this issue · 2 comments

(As briefly discussed through email in the end of 2019)
Sally7 provides a great experience for the client part of the S7 protocol, but there is no server component at the moment. It means you need a real server or use another library to create an emulator if you want to test your code.

I've been using Snap7 in the mean time, but it would be nice to have a complete Sally7 experience for the S7 protocol.

Here an example adapted from the code provided by Snap7 for a basic S7 server emulator, using Snap7:

using Snap7.Managed;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace X
{
    class RobotAIServer
    {
        byte[] _database1 = new byte[260];  // Our DB1

        public event EventHandler ReceivedData;

        void eventCallback(IntPtr usrPtr, ref S7Server.USrvEvent Event, int Size)
        {
            Logger.Instance.Log("Event Callback");
            if (Event.EvtCode.Equals(S7Server.evcDataRead))
            {
                Logger.Instance.Log("Sent data to client");
            }
            if (Event.EvtCode.Equals(S7Server.evcDataWrite))
            {
                Logger.Instance.Log("Received data from client.");
                ReceivedData?.Invoke(this, EventArgs.Empty);
                hexDump(_database1, _database1.Length);
            }
        }

        void readEventCallback(IntPtr usrPtr, ref S7Server.USrvEvent Event, int Size)
            => Logger.Instance.Log("Read Event Callback");

        static void hexDump(byte[] bytes, int Size)
        {
            if (bytes == null)
            {
                return;
            }

            int bytesLength = Size;
            const int BYTES_PER_LINE = 16;

            char[] HexChars = "0123456789ABCDEF".ToCharArray();

            const int FIRST_HEX_COLUMN =
                  8                   // 8 characters for the address
                + 3;                  // 3 spaces

            const int FIRST_CHAR_COLUMN = FIRST_HEX_COLUMN
                + (BYTES_PER_LINE * 3)       // - 2 digit for the hexadecimal value and 1 space
                + ((BYTES_PER_LINE - 1) / 8) // - 1 extra space every 8 characters from the 9th
                + 2;                  // 2 spaces

            int lineLength = FIRST_CHAR_COLUMN
                + BYTES_PER_LINE           // - characters to show the ascii value
                + Environment.NewLine.Length; // Carriage return and line feed (should normally be 2)

            char[] line = ((new string(' ', lineLength - 2)) + Environment.NewLine).ToCharArray();
            int expectedLines = ((bytesLength + BYTES_PER_LINE) - 1) / BYTES_PER_LINE;
            var result = new StringBuilder(expectedLines * lineLength);

            for (int i = 0; i < bytesLength; i += BYTES_PER_LINE)
            {
                line[0] = HexChars[(i >> 28) & 0xF];
                line[1] = HexChars[(i >> 24) & 0xF];
                line[2] = HexChars[(i >> 20) & 0xF];
                line[3] = HexChars[(i >> 16) & 0xF];
                line[4] = HexChars[(i >> 12) & 0xF];
                line[5] = HexChars[(i >> 8) & 0xF];
                line[6] = HexChars[(i >> 4) & 0xF];
                line[7] = HexChars[(i >> 0) & 0xF];

                int hexColumn = FIRST_HEX_COLUMN;
                int charColumn = FIRST_CHAR_COLUMN;

                for (int j = 0; j < BYTES_PER_LINE; j++)
                {
                    if ((j > 0) && ((j & 7) == 0))
                    {
                        hexColumn++;
                    }

                    if (i + j >= bytesLength)
                    {
                        line[hexColumn] = ' ';
                        line[hexColumn + 1] = ' ';
                        line[charColumn] = ' ';
                    }
                    else
                    {
                        byte b = bytes[i + j];
                        line[hexColumn] = HexChars[(b >> 4) & 0xF];
                        line[hexColumn + 1] = HexChars[b & 0xF];
                        line[charColumn] = (b < 32) ? '·' : ((char)b);
                    }
                    hexColumn += 3;
                    charColumn++;
                }
                result.Append(line);
            }
            Logger.Instance.Log(result.ToString());
        }

        public async Task RunAsync(CancellationToken token)
        {
            var serverEvent = new S7Server.USrvEvent();
            var server = new S7Server();
            server.RegisterArea(S7Server.srvAreaDB,  // We are registering a DB
                                1,                   // Its number is 1 (DB1)
                                ref _database1,                 // Our buffer for DB1
                                _database1.Length);         // Its size

            var eventCallBack = new S7Server.TSrvCallback(eventCallback);
            var readCallBack = new S7Server.TSrvCallback(readEventCallback);
            server.SetEventsCallBack(eventCallBack, IntPtr.Zero);
            server.SetReadEventsCallBack(readCallBack, IntPtr.Zero);

            server.EventMask &= 1; // Mask 00000000
            server.EventMask |= S7Server.evcDataRead;
            server.EventMask |= S7Server.evcDataWrite;

            int Error = server.Start();
            if (Error == 0)
            {
                do
                {
                    while (server.PickEvent(ref serverEvent))
                    {
                        Logger.Instance.Log(server.EventText(ref serverEvent));
                        // TODO : EventText would throw on second command - push that in a separate thread ?
                    }
                    await Task.Delay(10).ConfigureAwait(true);
                }
                while (!token.IsCancellationRequested);
                server.Stop();
            }
        }

        public void WriteToDatabase(int index, byte value) => _database1[index] = value;

        public byte ReadFromDatabase(int index) => _database1[index];

        public string ReadStringFromDatabase(int index, int stringLength)
        {
            var str = new List<char>();
            for (int i = 0; i < stringLength; i++)
            {
                str.Add((char)ReadFromDatabase(index + i));
            }

            return new string(str.ToArray());
        }
    }
}

S7NetPlus uses Snap7 in its unit tests. I think most of the code for that is in https://github.com/S7NetPlus/s7netplus/blob/develop/S7.Net.UnitTest/Helpers/S7TestServer.cs . Your implementation using Snap7.Managed might be better though.

I was able to get that Snap7 unit test in S7NetPlus working on net core linux and windows in S7NetPlus/s7netplus#318, while also removing the Port 102 dependency.
That makes it a bit easier to setup a test environment and do multi-platform testing.

I don't think all of that is ever a replacement for true integration testing using S7 PLCs, but it could definitely be a part of automated unit/integration tests.

It's actually a really interesting problem on its own. If I implement server side support in Sally7, I intend to share as much code as possible for the data structs. Evidently what happens if I change a struct is that both client and server will change, so the question is how valuable a unit test against Sally7 as server would be. On the other hand testing against another S7 implementation should not be affected by internal refactorings, so there's something to say for that as well. Still, I don't feel like introducing a dependency on Snap7 in any way, I guess that library just doesn't live up to my expectations of code quality.

As far as proper unit testing is concerned, it would mostly be important to actually separate out the units. Currently there's a mixture of connection management and structuring protocol data in the S7Connection class, neither of which can currently be tested without the other. It's not high on my to do list, but I guess it's something I will change. When it has changed we can just do writes against a MemoryStream (or whatever, I'm actually playing with PipeReader this evening) and verify the data to ensure protocol validity. Connection management (as far as it exists) could then be tested separately as well.

Anyway, I'm somehow currently very motivated to make some next steps with Sally7 (and possibly S7NetPlus as well), so maybe the next few days/weeks might provide some improvements.