Работа с Arduino из C# приложения

f6e38b409f82458dac772d094fc991d3.png


В этой статье я хотел бы рассказать о том, как можно считывать данные и управлять платой Arduino, подключенной через USB порт, из .Net приложения и из приложения UWP.

Делать это можно без использования сторонних библиотек. Фактически, используя только виртуальный COM порт.
Давайте сначала напишем скетч для Arduino. Мы будем отправлять на порт строку с текстом, содержащим в себе значение переменной, которая у нас будет постоянно изменятся в цикле (таким образом имитируя данные снятые с датчика). Также будем считывать данные с порта и в случае если получим текст »1», то включим встроенный в плату светодиод. Он расположен на 13-ом пине и помечен на плате латинской буквой L. А если получим »0», то выключим его.

int i = 0;  // переменная для счетчика имитирующего показания датчика
int led = 13; 

void setup() {
  Serial.begin(9600);    // установим скорость обмена данными
  pinMode(led, OUTPUT);  // и режим работы 13-ого цифрового пина в качестве выхода
}
void loop() {
  i = i + 1;  // чтобы мы смогли заметить что данные изменились
  String stringOne = "Info from Arduino ";
  stringOne += i;  // конкатенация
  Serial.println(stringOne);  // отправляем строку на порт

  char incomingChar;
  
  if (Serial.available() > 0)
  {
    // считываем полученное с порта значение в переменную
    incomingChar = Serial.read();  
   // в зависимости от значения переменной включаем или выключаем LED
    switch (incomingChar) 
    {
      case '1':
        digitalWrite(led, HIGH);
        break;
      case '0':
        digitalWrite(led, LOW);
        break;
    }
  }
  delay(300);
}


WPF приложение


Теперь создадим WPF приложение. Разметку сделаем довольно простой. 2 кнопки и метка для отображения текста полученного с порта это все что необходимо:

 
 
 
 
 


Добавим 2 пространства имен:

using System.Timers;
using System.IO.Ports;


И в области видимости класса 2 переменные с делегатом:

System.Timers.Timer aTimer;
SerialPort currentPort;
private delegate void updateDelegate(string txt);


Реализуем событие Window_Loaded. В нем мы пройдемся по всем доступным портам, прослушаем их и проверим не выводится ли портом сообщение с текстом «Info from Arduino». Если найдем порт отправляющий такое сообщение, то значит нашли порт Arduino. В таком случае можно установить его параметры, открыть порт и запустить таймер.

           bool ArduinoPortFound = false;

            try
            {
                string[] ports = SerialPort.GetPortNames();
                foreach (string port in ports)
                {
                    currentPort = new SerialPort(port, 9600);
                    if (ArduinoDetected())
                    {
                        ArduinoPortFound = true;
                        break;
                    }
                    else
                    {
                        ArduinoPortFound = false;
                    }
                }
            }
            catch { }

            if (ArduinoPortFound == false) return;
            System.Threading.Thread.Sleep(500); // немного подождем

            currentPort.BaudRate = 9600;
            currentPort.DtrEnable = true;
            currentPort.ReadTimeout= 1000;
            try
            {
                currentPort.Open();
            }
            catch { }

            aTimer = new System.Timers.Timer(1000);
            aTimer.Elapsed += OnTimedEvent;
            aTimer.AutoReset = true;
            aTimer.Enabled = true;


Для снятия данных с порта и сравнения их с искомыми я использовал функцию ArduinoDetected:

        private bool ArduinoDetected()
        {
            try
            {
                currentPort.Open();
                System.Threading.Thread.Sleep(1000); 
   // небольшая пауза, ведь SerialPort не терпит суеты

                string returnMessage = currentPort.ReadLine();
                currentPort.Close();

   // необходимо чтобы void loop() в скетче содержал код Serial.println("Info from Arduino");
                if (returnMessage.Contains("Info from Arduino"))
                {
                    return true;
                }
                else
                {
                    return false;
                }
            }
            catch (Exception e)
            {
                return false;
            }
        }


Теперь осталось реализовать обработку события таймера. Метод OnTimedEvent можно сгенерировать средствами Intellisense. Его содержимое будет таким:

        private void OnTimedEvent(object sender, ElapsedEventArgs e)
        {
           if (!currentPort.IsOpen) return;
           try // так как после закрытия окна таймер еще может выполнится или предел ожидания может быть превышен
           {
               // удалим накопившееся в буфере
                currentPort.DiscardInBuffer();  
               // считаем последнее значение 
                string strFromPort = currentPort.ReadLine();               
                lblPortData.Dispatcher.BeginInvoke(new updateDelegate(updateTextBox), strFromPort);
           }
           catch { }
        }

        private void updateTextBox(string txt)
        {
            lblPortData.Content = txt;
        }


Мы считываем значение с порта и выводим его в виде текста метки. Но так как таймер у нас работает в потоке отличном от потока UI, то нам необходимо использовать Dispatcher.BeginInvoke. Вот здесь нам и пригодился объявленный в начале кода делегат.

После окончания работы с портом очень желательно его закрыть. Но так как мы работаем с ним постоянно, пока приложение открыто, то закрыть его логично при закрытии приложения. Добавим в наше окно обработку события Closing:

        private void Window_Closing(object sender, EventArgs e)
        {
            aTimer.Enabled = false;
            currentPort.Close();
        }


Готово. Теперь осталось сделать отправку на порт сообщения с текстом »1» или »0», в зависимости от нажатия кнопки и можно тестировать работу приложения. Это просто:

        private void btnOne_Click(object sender, RoutedEventArgs e)
        {
            if (!currentPort.IsOpen) return;
            currentPort.Write("1");
        }

        private void btnZero_Click(object sender, RoutedEventArgs e)
        {
            if (!currentPort.IsOpen) return;
            currentPort.Write("0");
        }


Получившийся пример доступен на GitHub.

Кстати, WinForms приложение создается еще быстрее и проще. Достаточно перетянуть на форму из панели инструментов элемент SerialPort (при наличии желания можно перетянуть из панели инструментов и элемент Timer). После чего в нужном месте кода, можно открыть порт, считывать из него данные и писать в него примерно как и в WPF приложении.

UWP приложение


Для того чтобы разобраться с тем как работать с последовательным портом я рассмотрел следующий пример: SerialSample
Для разрешения работы с COM портом в манифесте приложения должно быть такое вот объявление:

  
    
  
  
    
  

  


В коде C# нам понадобятся 4 пространства имен:

using Windows.Devices.SerialCommunication;
using Windows.Devices.Enumeration;
using Windows.Storage.Streams;
using System.Threading.Tasks;


И одна переменная в области видимости класса:

     string deviceId;


При загрузке считаем в нее значение id порта к которому подключена плата Arduino:

 private async void Page_Loaded(object sender, RoutedEventArgs e)
 {
  string filt = SerialDevice.GetDeviceSelector("COM3");
  DeviceInformationCollection devices = await DeviceInformation.FindAllAsync(filt);

  if (devices.Any())
  {
     deviceId = devices.First().Id;
  }
 }


Следующий Task считает 64 байта с порта и отобразит текст в поле с именем txtPortData

private async Task Listen()
        {
     using (SerialDevice serialPort = await SerialDevice.FromIdAsync(deviceId))
            {
                if (serialPort != null)
                {
                    serialPort.ReadTimeout = TimeSpan.FromMilliseconds(1000);
                    serialPort.BaudRate = 9600;
                    serialPort.Parity = SerialParity.None;
                    serialPort.StopBits = SerialStopBitCount.One;
                    serialPort.DataBits = 8;
                    serialPort.Handshake = SerialHandshake.None;

                  try
                  {
      using (DataReader dataReaderObject = new DataReader(serialPort.InputStream))
                     {
             Task loadAsyncTask;
              uint ReadBufferLength = 64;
              dataReaderObject.InputStreamOptions = InputStreamOptions.Partial;
              loadAsyncTask = dataReaderObject.LoadAsync(ReadBufferLength).AsTask();
              UInt32 bytesRead = await loadAsyncTask;   
                       if (bytesRead > 0)
                       {
                           txtPortData.Text = dataReaderObject.ReadString(bytesRead);
                           txtStatus.Text = "Read operation completed";
                       }
                     }
                  }
                  catch (Exception ex)
                  {
                      txtStatus.Text = ex.Message;
                  }
                }
            }
        }


В UWP приложениях на C# отсутствует метод SerialPort.DiscardInBuffer. Поэтому один из вариантов, это считывать данные открывая каждый раз порт заново, что и было продемонстрировано в данном случае. Если вы попробуете, то сможете заметить, что отсчет каждый раз идет с единицы. Примерно то же самое происходит и в Arduino IDE при открытии Serial Monitor. Вариант, конечно, так себе. Открывать каждый раз порт это не дело, но если данные необходимо считывать редко, то этот способ сойдет. Кроме того, таким образом записанный пример выглядит короче и понятнее.

Рекомендуемый вариант это не объявлять каждый раз порт заново, а объявить его один раз, например, при загрузке. Но в таком случае необходимо будет регулярно считывать данные с порта, чтобы он не заполнялся старьем и данные оказывались актуальными. Смотрите как это сделано в моем примере UWP приложения. Я так полагаю, что концепт отсутствия возможности очистить буфер состоит в том, что постоянно асинхронно снимаемые данные, не особо нагружают систему. Как только необходимое количество байт считывается в буфер, выполняется следом написанный код. Есть плюс, в том, что при таком постоянном мониторинге ничего не пропустишь, но некоторым (и мне в том числе) не хватает привычной возможности один раз «считнуть» данные.

Для записи данных в порт можно использовать схожий код:

  private async Task sendToPort(string sometext)
        {

  using (SerialDevice serialPort = await SerialDevice.FromIdAsync(deviceId))
            {
                Task.Delay(1000).Wait(); 

                if ((serialPort != null) && (sometext.Length != 0))
                {
                    serialPort.WriteTimeout = TimeSpan.FromMilliseconds(1000);
                    serialPort.ReadTimeout = TimeSpan.FromMilliseconds(1000);
                    serialPort.BaudRate = 9600;
                    serialPort.Parity = SerialParity.None;
                    serialPort.StopBits = SerialStopBitCount.One;
                    serialPort.DataBits = 8;
                    serialPort.Handshake = SerialHandshake.None;

                    Task.Delay(1000).Wait();

                    try
                    {

    using (DataWriter dataWriteObject = new DataWriter(serialPort.OutputStream))
                        {

                            Task storeAsyncTask;

                            dataWriteObject.WriteString(sometext);

                            storeAsyncTask = dataWriteObject.StoreAsync().AsTask();

                            UInt32 bytesWritten = await storeAsyncTask;

                            if (bytesWritten > 0)
                            {
                                txtStatus.Text = bytesWritten + " bytes written";
                            }
                        }
                    }
                    catch (Exception ex)
                    {
                        txtStatus.Text = ex.Message;
                    }
                }
            }
        }


Вы можете заметить, что после инициализации порта и установки параметров добавлены паузы по 1 секунде. Без этих пауз заставить Arduino среагировать не получилось. Похоже, что serial port действительно не терпит суеты. Опять же, напоминаю, что лучше открыть порт один раз, а не открывать/закрывать его постоянно. В таком случае никакие паузы не нужны.
Упрощенный вариант UWP приложения, который аналогичен рассмотренному выше WPF .Net приложению доступен на GitHub

В результате я пришел к выводу, что работа в UWP с Arduino напрямую через виртуальный COM порт хоть и непривычна, но вполне возможна и без подключения сторонних библиотек.
Замечу, что Microsoft довольно тесно сотрудничает с Arduino, поэтому различных библиотек и технологий коммуникации, упрощающих разработку, множество. Самая популярная, это конечно же работающая с протоколом Firmata библиотека Windows Remote Arduino.

© Habrahabr.ru