Search code examples
c#multithreadingasynchronousarduinotask

Is there a way to get multiple async methods (tasks or threads) to wait until certain individual conditions happen?


I am trying to get a call/response system working with an arduino controlling a mechanism via the serial port.

The controlling computer creates a Task/thread that handles each job assigned to the arduino asynchronously (home the mechanism, set position, move, etc.) The arduino reports back with how it is doing on each job ("successfully homed", "moved to position", etc.) which allows the job to continue along its code path.

However, everything I've tried has some issue with it, and so I'm looking for some advice on how to proceed.

Here are things I've tried. (Feel free to ask specifics about these.)

  • while(!doneWaiting){thread.sleep(5);} loops when I was using a thread-based system instead of a task based system. This ate up the entire CPU when I had multiple threads waiting for a response from the arduino (for example, I was trying to moveAxis1 and moveAxis4 at the same time, and both were waiting for permission to proceed, crashing the program due to over-use of memory.)

  • Repeatedly parsing the SerialPort input for the string that signified for that job to proceed. Also ate up memory and would occasionally ignore the "OK to proceed with job" response.

  • Eventually I implemented the serialPort.DataReceived event and used ManualResetEvents.WaitOne(). When new data was found in the SerialPort, it would ManualResetEvents.Set()and allow the code to flow past WaitOne(). This would be the best option, but it triggers ALL waiting threads as opposed to one particular waiting thread at a time. I need some threads to wait for their personal response while others are allowed to continue.

Here is my current implementation, which is nonfunctional.

class Program
{
    public static SerialPort serialPort = new SerialPort("COM9", 9600, Parity.None, 8, StopBits.One);
    public static string newDataContent = string.Empty;
    public static bool homed = false;
    static TaskCompletionSource<bool> homeRelease = new TaskCompletionSource<bool>(); //this is the important bit

    static void Main(string args[])
    {
    serialPort.DtrEnable = true;
    serialPort.DataReceived += new SerialPortDataReceivedEventHandler(serialPort_ReadArduinoData);

    serialPort.Open();

    //...

    char cmdType = Console.ReadLine().ToLower().ToCharArray()[0];
    switch(cmdType)
        {
        case 'h':
            
            Task.Run(Home);
     
        break;
        
        //...the rest of the commands...

        }
    }
}

Here is the what the Home task looks like. It is paused in the middle with `homeRelease.Task;` and awaits its completion.

static async Task Home()
    {
        Console.WriteLine("Running Task Home()");
        WriteMyCommand(2, 0, serialPort); //this function is proven working. Writes the command to the arduino.
        Console.WriteLine("awaiting homeRelease...");
        await homeRelease.Task;
        Console.WriteLine("...homeRelease Received");
        homed = true;
        homeRelease.SetResult(false);
    }

The serialData_ReadArduinoData event looks like this:

public static void serialPort_ReadArduinoData(object sender, SerialDataReceivedEventArgs e)
{
    SerialPort spL = (SerialPort)sender;
    byte[] buf = new byte[spL.BytesToRead];
    spL.Read(buf,0,buf.Length);
    newDataContent = $"{System.Text.Encoding.ASCII.GetString(buf)}";

    Console.WriteLine($"Received: {newDataContent}");

    switch(newDataContent[1])
    {
        case '2': //response Home command
            Console.WriteLine("Detected home response");
            homeRelease.SetResult(true);
            break;
        //rest of switch statement
    }
}

When I do one home command, the system works as expected, and pauses the Home task at the await until the "arduino has homed" signal is complete. However, when I request another instance of the same job, I get the error, 'An attempt was made to transition a task to a final state when it had already completed.'

Is there a way I could get multiple of these threads/tasks/jobs to wait for an individual flag raised by the switch statement in serialPort_ReadArduinoData that allows them to continue working? Or is this approach just completely deranged?

I am totally stumped as to where I could go next. Any assistance would be very much appreciated: I am over deadline and also not a professional programmer.


Solution

  • Your general question is about executing tasks in multiple stages, but specifically "trying to get a call/response system working with an Arduino". In that case, you could experiment with designing an AwaitableCommand base class along with a Queue<AwaitableCommand> structure to run any number of derived actions sequentially until the queue is empty. (For example, using your other question and my answer as a basis, you show a Home command that waits for "home done", and an XY seeking command that waits for both "x done" and "y done" which can occur in either order.) An additional benefit is that any collection of AwaitableCommand could be easily written to, and reloaded from, a JSON file in order to save routines and load them in bulk to the queue.


    OP's question has gotten several upvotes so I'm attempting something of a canonical answer having worked with Linduino in test environments at LTC and ADI.


    Awaitable Commands...

    To solve the problem of interacting with the same task awaiter multiple times, any new instance of a command will have its own (initially blocked) semaphore. This is going to change what it means when you say "request another instance of the same job" because now for example each new instance of HomeCommand will have an entirely new instance of the awaiter as well.

    public abstract class AwaitableCommand
    {
        public abstract TaskAwaiter GetAwaiter();
        public override string ToString() => this.GetType().Name;
    }
    public class HomeCommand : AwaitableCommand
    {
        public SemaphoreSlim Busy { get; } = new SemaphoreSlim(0, 1);
        public override TaskAwaiter GetAwaiter() => 
            Busy
            .WaitAsync()
            .GetAwaiter();
    }
    /// <summary>
    /// Wait for X and Y in either order
    /// </summary>
    public class XYCommand : AwaitableCommand
    {
        public int? X { get; set; }
        public int? Y { get; set; }
        public bool Valid => X != null || Y != null;
        public SemaphoreSlim BusyX { get; } = new SemaphoreSlim(0, 1);
        public SemaphoreSlim BusyY { get; } = new SemaphoreSlim(0, 1);
    
        public override TaskAwaiter GetAwaiter()
        {
            return localReady().GetAwaiter();
            async Task localReady()
            {
                var tasks = new List<Task>();
                if (X != null)
                    tasks.Add(BusyX.WaitAsync());
                if (Y != null)
                    tasks.Add(BusyY.WaitAsync());
                await Task.WhenAll(tasks);
            }
        }
        public override string ToString()
        {
            var builder = new StringBuilder(this.GetType().Name);
            if (X != null)
                builder.Append($" {X}");
            if (Y != null)
                builder.Append($" {Y}");
            return builder.ToString();
        }
    }
    /// <summary>
    /// Program delay on PC side (not in Arduino)
    /// </summary>
    public class DelayCommand : AwaitableCommand
    {
        public int? Delay { get; set; }
        public override TaskAwaiter GetAwaiter() =>
            Task
            .Delay(TimeSpan.FromMilliseconds(Delay ?? 0))
            .GetAwaiter();
        public override string ToString()
        {
            var builder = new StringBuilder(this.GetType().Name);
            if (Delay != null)
                builder.Append($" {Delay}");
            return builder.ToString();
        }
    }
    

    ... And the Queue that runs them

    You mentioned (offline) that the Arduino can run concurrent processes, so a command like XYCommand might "Fire and Forget" two processes and then await for its BusyX and BusyY semaphores to be released in either order.

    public class ArduinoComms : Queue<AwaitableCommand>
    {
        object _critical = new object();
        SemaphoreSlim _running = new SemaphoreSlim(1, 1);
        public new void Enqueue(AwaitableCommand command)
        {
            lock (_critical)
            {
                base.Enqueue(command);
            }
            RunQueue();
        }
        public void EnqueueAll(IEnumerable<AwaitableCommand> commands)
        {
            lock (_critical)
            {
                foreach (var command in commands) base.Enqueue(command);
            }
            RunQueue();
        }
    
        AwaitableCommand? _currentCommand = default;
    
        private async void RunQueue()
        {
            // Do not allow reentry
            if (_running.Wait(0))
            {
                try
                {
                    while (true)
                    {
                        lock (_critical)
                        {
                            if (this.Any())
                            {
                                _currentCommand = Dequeue();
                            }
                            else _currentCommand = null;
                        }
                        if (_currentCommand is null)
                        {
                            Logger("QUEUE EMPTY");
                            return;
                        }
                        else
                        {
                            Logger($"RUNNING: {_currentCommand}");
                            switch (_currentCommand)
                            {
                                case AwaitableCommand cmd when cmd is HomeCommand home:
                                    StartArduinoProcess(cmd: 2);
                                    await home;
                                    break;
                                case AwaitableCommand cmd when cmd is XYCommand xy:
                                    if (xy.X is int x)
                                    {
                                        StartArduinoProcess(cmd: 0);
                                    }
                                    else xy.BusyX.Release();
                                    if (xy.Y is int y)
                                    {
                                        StartArduinoProcess(cmd: 1);
                                    }
                                    else xy.BusyY.Release();
                                    await xy;
                                    break;
                                case AwaitableCommand cmd when cmd is DelayCommand delay:
                                    // Spin this here, on the client side.
                                    // Don't make Arduino do it.
                                    await delay;
                                    Logger($"Delay Done {delay.Delay}");
                                    break;
                                default:
                                    Logger("UNRECOGNIZED COMMAND");
                                    break;
                            }
                        }
                    }
                }
                finally
                {
                    _running.Release();
                }
            }
        }
        .
        .
        .
    }
    

    Arduino Rx

    I've incorporated this idea into your original receiver method as a starting point. As an improvement to your code, consider checking the spL.BytesToRead against the number of bytes you're expecting because it's possible to get a partial return. In other words, if the command is expecting "home done\n" then check for System.Text.Encoding.ASCII.GetBytes("home done\n").Length and spin until the Arduino has pushed ALL the bytes into its RX buffer.

    private void Port_DataReceived(object sender, SerialDataReceivedEventArgs e)
    {
        byte[] buf;
        switch (sender)
        {
            case object o when o is SerialPort spL:
                buf = new byte[spL.BytesToRead]; //instantiates a buffer of appropriate length.
                spL.Read(buf, 0, buf.Length); //reads from the sender, which inherits the data from the sender, which *is* our serial port.
                break;
            case object o when o is MockSerialPort mspL:
                buf = mspL.SimBuffer;
                break;
            default: throw new NotImplementedException();
        }
    
        var ascii = $"{System.Text.Encoding.ASCII.GetString(buf)}"; //assembles the byte array into a string.
        Logger($"Received: {ascii}"); //prints the result for debug.
        string[] thingsToParse = ascii.Split('\n'); //splits the string into an array along the newline in case multiple responses are sent in the same message.
    
        foreach (string thing in thingsToParse) //checks each newline instance individually.
        {
            try
            {
                switch (thing)
                {
                    case string c when c.Contains("home done", StringComparison.OrdinalIgnoreCase): //checks incoming data for the arduino's report phrase "Home done" when it is homed.
                        if(_currentCommand is HomeCommand home)
                        {
                            home.Busy.Release();
                        }
                        else Debug.Fail("Expecting response to match current command.");
                        Logger($"Homed");
                        break;
                    case string c when c.Contains("x done", StringComparison.OrdinalIgnoreCase):
                        if (_currentCommand is XYCommand xProcess)
                        {
                            xProcess.BusyX.Release();
                            Logger($"X Done {xProcess.X}");
                        }
                        else Debug.Fail("Expecting response to match current command.");
                        break;
                    case string c when c.Contains("y done", StringComparison.OrdinalIgnoreCase):
                        if (_currentCommand is XYCommand yProcess)
                        {
                            yProcess.BusyY.Release();
                            Logger($"Y Done {yProcess.Y}");
                        }
                        else Debug.Fail("Expecting response to match current command.");
                        break;
    
                    default: break; //do nothing
                }
            }
            catch (Exception)
            {
                // DO: figure out what went wrong, because this shouldn't happen
                // DON'T: Crash
            }
        }
    }
    

    Demo (WinForms)

    The left panel shows eight awaitable commands that have been staged in memory using the [Mem+] button.

    The right panel shows the effect of clicking the [Run] menu item which rapidly dumps the list into the run queue.

    The log shows:

    • The Home command completing before X or Y.
    • Multiple repetitions of XY command, where X and Y are awaited, and might come back in either order, but this transaction will be "atomic" in the sense that both X and Y must release before the queue will advance to the next program step.
    • A delay, if specified, will execute on the PC side (rather than spin on the Arduino side) before advancing the queue.

    logs

    public partial class CommandComposerForm : Form
    {
        public CommandComposerForm()
        {
            InitializeComponent();
            ArduinoComms = new ArduinoComms();
            ArduinoComms.Log += Log;
            
            buttonEnqueue.Click += (sender, e) =>
            {
                // Add one or more commands to queue based 
                // on valid (or not) values in UI controls.
                if(checkBoxHome.Checked) ArduinoComms.Enqueue(new HomeCommand());
                var xyCommand = new XYCommand();
                if(int.TryParse(textBoxX.Text, out int x)) xyCommand.X = x;
                if(int.TryParse(textBoxY.Text, out int y)) xyCommand.Y = y;
                if(xyCommand.Valid) ArduinoComms.Enqueue(xyCommand);
                if (int.TryParse(textBoxDelay.Text, out int delay))
                {
                    var delayCommand = new DelayCommand { Delay = delay };
                    ArduinoComms.Enqueue(delayCommand);
                }
            };
            buttonMemPlus.Click += (sender, e) =>
            {
                AwaitableCommand command;
                if(checkBoxHome.Checked)
                {
                    command = new HomeCommand();
                    Memory.Add(command);
                    Log(this, new LoggerMessageArgs($"MEMORY: {command}", false));
                }
                var xyCommand = new XYCommand();
                if(int.TryParse(textBoxX.Text, out int x)) xyCommand.X = x;
                if(int.TryParse(textBoxY.Text, out int y)) xyCommand.Y = y;
                if(xyCommand.Valid)
                {
                    Memory.Add(xyCommand);
                    Log(this, new LoggerMessageArgs($"MEMORY: {xyCommand}", false));
                }
                if (int.TryParse(textBoxDelay.Text, out int delay))
                {
                    var delayCommand = new DelayCommand { Delay = delay };
                    Memory.Add(delayCommand);
                    Log(this, new LoggerMessageArgs($"MEMORY: {delayCommand}", false));
                }
            };
            runToolStripMenuItem.Click += (sender, e) =>
            {
                richTextBox.Clear();
                ArduinoComms.EnqueueAll(Memory);
            };
            
            void Log(object? sender, LoggerMessageArgs e, bool timeStamp = true)
            {
                // Marshall onto UI thread because SerialPort
                // is usually running on a background thread.
                BeginInvoke(() =>
                {
                    if(ReferenceEquals(sender, this)) richTextBox.SelectionColor = Color.Blue;
                    else richTextBox.SelectionColor = Color.Black;
                    if(e.IncludeTimeStamp) richTextBox.AppendText($@"{DateTime.Now:hh\:mm\:ss\.ffff}: {e.Message}{Environment.NewLine}");
                    else richTextBox.AppendText($@"{e.Message}{Environment.NewLine}");
                    richTextBox.SelectionStart = richTextBox.Text.Length;
                    richTextBox.ScrollToCaret();
                });
            }
            .
            .
            .
            ObservableCollection<AwaitableCommand> Memory { get; } = 
                new ObservableCollection<AwaitableCommand>();
        }
    }