В этой статье я хотел бы рассказать о том, как можно считывать данные и управлять платой 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 кнопки и метка для отображения текста полученного с порта это все что необходимо:

 <StackPanel Orientation="Vertical">
 <Label x:Name="lblPortData" FontSize="48" HorizontalAlignment="Center" Margin="0,20,0,0">Нет данных</Label>
 <Button x:Name="btnOne" Click="btnOne_Click" Width="100" Height="30" Margin="0,10,0,0">Послать 1</Button>
 <Button x:Name="btnZero" Click="btnZero_Click" Width="100" Height="30" Margin="0,10,0,0">Послать 0</Button>
 </StackPanel>

Добавим 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 портом в манифесте приложения должно быть такое вот объявление:

  <Capabilities>
    <Capability Name="internetClient" />
  <DeviceCapability Name="serialcommunication">
  <Device Id="any">
    <Function Type="name:serialPort"/>
  </Device>
</DeviceCapability>
  </Capabilities>

В коде 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<UInt32> 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<UInt32> 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.

Комментарии (23)


  1. HexGrimm
    26.01.2016 18:49
    +2

    Касательно непосредственно кода:
    Приведенный выше код не так уж и удобно читать. В некоторых случаях есть переносы строк и отступы, в некоторых нет. Тоже самое по поводу открывающихся фигурных скобок. По поводу кода на GitHub я предложу исправления, но в статье рекомендую пользоваться чем нибудь общепринятым (Как вариант).
    А за статью — спасибо!


    1. asommer
      26.01.2016 18:53
      +1

      Спасибо за комментарий, вчитку, конструктивную критику и за благодарность!
      Для форматирования кода использую комбинацию Ctrl+K+D, но в данном случае частенько строки не помещались на странице, поэтому приходилось сдвигать.


  1. ZlodeiBaal
    26.01.2016 19:27
    +5

    Что это делает на Хабре? Ладно ещё на гиктаймс. Но этоже, пардон «СОМ порт под С#». При этом плохо написанный с непонятно зачем приплетённой ардуиной. Запрос «COM порт под С#» вбивается в гугл и получается десяток куда долее адекватных статей.


    1. Quiensabe
      26.01.2016 21:19
      +2

      А мне непонятно, что такие комментарии делают на хабре? Неужели сложно быть добрее к чужому труду? Если статья не соответствует лично вашему уровню — ну пройдите мимо или минус влепите, коли хочется…

      Я вот, например, только начаю unity изучать и хочу в ней использовать связь с ардуино (опыта ни в c# ни в arduino нет вообще). Так для меня эта статья просто архиполезна! Статей на русском именно для начинающих в этой связке технологий почти нет. Или мне и таким как я тоже не место на Хабре?

      А потом удивляются почему в России не развита взаимоподдержка среди айтишников…

      Автор, спасибо большое за публикацию!


      1. ZlodeiBaal
        26.01.2016 21:36
        +2

        Ок. Следующую статью пишем о том как установить C#? Или о том, как считать данные с хард-диска?
        Я, безусловно, понимаю, что десять строчек кода растянутые в 50 и поданные с большим числом async'ов это очень ценная на Хабре статья. Уж точно ценнее, чем статья с Хабра, где на arduino делалась нормальная программа с взаимодействием с C#. Только вот админы, какие-то непонятливые, блог про ардуино разместили на Гиктаймсе. Но автор молодец, нашел как их обхитрить и тиснуть статью на Хабр!

        А потом удивляются почему в России не развита взаимоподдержка среди айтишников…

        Так лучше?


        1. asommer
          26.01.2016 21:52
          +2

          Но автор молодец, нашел как их обхитрить и тиснуть статью на Хабр!

          Автор никого не пытался обхитрить. На geektimes нет раздела где были бы статьи про разработку универсальных приложений Windows. Половина статьи про C# и приложение UWP. Вторая половина про .Net приложение.
          Следующую статью пишем о том как установить C#?

          Я не знаю как установить C#…


          1. ZlodeiBaal
            26.01.2016 23:52
            -3

            Автор никого не пытался обхитрить.

            Вы что, это же был сарказм!


        1. Quiensabe
          26.01.2016 23:05
          -1

          Ок, я просто сошлюсь на эту статью geektimes.ru/post/178747 (ее рейтинг намекает, что не только я разделяю мнение автора).


          1. ZlodeiBaal
            27.01.2016 00:48
            +1

            По-моему, вы не понимаете о чём я. Когда я могу помочь автору, даже если вижу, что он некомпетентен в вопросе, про который пишет, а то, что он сделал в статье, не стоит и выеденного яйца, я это делаю — habrahabr.ru/post/263291
            Главное, чтобы автор писал статью на Хабр в которой он попробовал сделать что-то нетривиальное.

            То, что написано в данной статье — куда хуже. Это просто взят мануал и… Опошлен кучей лишнего кода. Пройдите по ссылке которую я приложил выше. 10 строчек кода + один абзац объяснений. Неужели это менее информативно, чем данная статья?

            Что делает Хабр хабром? Явно не публикации о том, как настроить домашний вайфай (я не сомневаюсь, что и из этого можно сделать конфетку, но это надо попотеть). Прочитайте. Может эта статья и следует букве правил. Но она полностью противоречит духу.

            Хотели видеть на хабре статьи «Как подсчитать в Excel сумму столбца» или «как настроить Windows Firewall»?


            1. Quiensabe
              27.01.2016 01:54
              +3

              Боюсь вы так же не можете понять меня. Суть этой статьи не в том чтобы быть самым кратким/точным/полным/etc ответом на какой-то вопрос. Ее суть в том чтобы взять «за руку» человека без понимания этого направления и дать ему какие-то ориентиры, дать возможность задуматься о чем-то вообще для него новом.
              Не только информативностью меряется достоинство статьи.

              Просто с высоты вашего понимания — вам не видны мелочи, которые оказываются полезны для других людей.

              Например, я не программист, читая статью вдруг понял, что такое «пространства имен». Вам кажется, что настолько элементарные вещи нельзя не знать если есть хоть какой-то опыт? Месяц назад я написал игру обучающую веппскому языку, которая уже установлена в залах нескольких музеев республики. Обойдясь при этом без знания этого термина. Конечно я использовал пространства имен, но не знал, что они так называются.

              К чему я веду? Я узнал такой термин. Другой человек узнал что ардуино работает через usb порт, но представляет его com-портом (пол года назад это тоже стало бы для меня открытием). Третий решил проблему с определением номера порта к которому подключена ардуина. И так далее.

              Как этим людям поможет статья в 10 строк кода на которую вы указали? Никак. Скорее всего они просто закроют вкладку потому что не обладают базовым пониманием достаточным чтобы понять о чем там вообще идет речь.

              Но ведь цель Хабра не только в том чтобы дать быстрый и точный ответ профессионалу. Она и в том чтобы привлечь в IT новых людей, дать им ориентиры в новом для них мире. И чем это противоречит правилам Хабра?


              1. ZlodeiBaal
                27.01.2016 01:58
                -1

                Вы второй раз уходите от вопроса

                Хотели видеть на хабре статьи «Как подсчитать в Excel сумму столбца» или «как настроить Windows Firewall»?

                Где граница чуши которую можно запостить на хабр?

                Может у меня нет базового понимания того, как работает WinAmp. И я ещё со школы жду ангела просвещения который снизойдёт на Хабр и расскажет мне смысл кнопочек в нём. Вы бы хотели видеть статьи такого уровня на Хабре?


                1. Quiensabe
                  27.01.2016 02:48

                  Ок. Отвечу.

                  Если статья «как настроить Windows Firewall» будет содержать, разбор его внутреннего устройства или, например, информацию о настройках позволяющих оптимизировать работу пиринговых сетей в различных конфигурациях — то я за такую статью. Если же это будет копия главы из мануала — то против.

                  Если для этой статьи вы дадите ссылку на русский мануал, где подробно рассмотрено взаимодействие Arduino и программы на C# (вы упоминали, что такой есть) — я с радостью сниму свои возражения. Но если вы скажите, что есть мануал по работе с COM-портами на c#, а это «то же самое» — то я с вами не соглашусь, потому что оно «то же самое» — для вас и других людей с опытом. А для начинающих это вовсе не очевидно, и пусть в чем-то статья будет вторична, сам посыл базового понимания оправдывает ее присутствие на Хабре.


                  1. ZlodeiBaal
                    27.01.2016 02:56

                    Первая страница яндекса: 1,2,3, 4

                    На Хабре пример: 1

                    И это не считая того, что английский мануал на 1 абзац и 10 строчек кода практичнее и понятнее статьи.


                    1. Quiensabe
                      27.01.2016 03:54
                      +1

                      На Хабре пример: 1
                      это явно не «мануал о Arduino и C#». Хоть там есть то и другое, но цели, задачи, ЦА — все другое.

                      яндекс
                      1 — для начинающего это просто кусок кода. Никаких объяснений/понимания и т.п. от него не будет.
                      2 — пример с C# ни полнотой ни понятностью не отличается. У начинающего скорее всего вообще не запустится, потому как ардуино повиснет не на COM1
                      3 — на англ. И не для начинающих.
                      4 — обсуждение неработающего кода. Автор той темы — судя по всему был бы рад этой статье.

                      И я так и не вижу мануала из фразы «взят мануал и… Опошлен кучей лишнего кода»? Или тот мануал был на англ. а вы принципиально против переводов?

                      Неужели это плохо, взять (пусть даже известную) тему, но сделать перевод, расписать ее для новичков, снабдить подробными комментариями, разобрать частные случаи… Это вы называете «опошлить»?


                1. Iceg
                  28.01.2016 02:53

                  >Например, я не программист, читая статью вдруг понял, что такое «пространства имен»
                  Предположу, что хабр всё таки профильный ресурс, и наличие БАЗОВОГО ПОНИМАНИЯ подразумевается само собой.


                  1. asommer
                    28.01.2016 08:11

                    Кроме того, само собой подразумевается умение установить C#.
                    Нужно при регистрации на хабре тестирование сделать. И задавать вопросы вроде «Как установить C#?». Ответишь на 9 из 10 вопросов — можешь читать хаб. А если не ответишь — go away. Нельзя таким читать. Можно читать только тем кто все уже знает.
                    Можешь привести хоть одну ссылку, где в рунете есть статья именно на такую же тему (в данном случае UWP/виртуальный COM) или где конкретно ошибка — можешь поругать в комментариях.


                    1. Iceg
                      28.01.2016 18:04

                      Тогда и я буду паясничать — а лучше тогда вообще не писать статьи, требующие некоторого предварительного знания материала, потому что не каждый обладает достаточными БАЗОВЫМИ ЗНАНИЯМИ и навыками гугления, чтобы такой материал воспринять. Равноправие и толерантность. А ещё запретить читать, а тем более писать, если не принадлежишь к обижаемым меньшинствам.


      1. kAIST
        26.01.2016 22:48

        Статей на русском именно для начинающих в этой связке технологий почти нет

        Да какая связка технологий то. Это обычный com порт, как с ним работать, гуглится за пару минут для любого популярного языка программирования.


      1. kahi4
        27.01.2016 00:06

        Гугл отменили? Одних видео самого разного качества несколько тысяч. Даже ассет для этого есть.

        Более того, конкретно в этой статье есть что называется bad practices. Например,

        string filt = SerialDevice.GetDeviceSelector("COM3");
        

        Ну а если ардуино не на COM3?


        1. Quiensabe
          27.01.2016 01:05
          +1

          Ну а если ардуино не на COM3?
          В статье рассмотрен вариант определения порта на котором висит ардуино.

          И если конкретно о моей ситуации, то у меня плохо с английским. А на русском результатов для начинающих по этой теме куда меньше. Даже на тостере были подобные вопросы, так что развернутый ответ вполне востребован.

          На счет «bad practices» — для тех кто уже имеет базовые знания это безусловно важно, но при попытках разобраться в совершенно новой области — всегда есть некий оптимальный уровень углубления в тему, при превышении которого те самые базовые принципы начинают ускользать и вместо «правильного понимания» — остается только ощущение избыточной сложности и неразберихи.

          К примеру, если бы вы решили разобраться в новой для себя области, скажем в генетике, то вряд-ли вам бы пошло на пользу сразу углубиться во все возможные детали. А вот провести интересный вам лично, несложный эксперимент, с наглядным практическим результатом и основанный на простых, понятных принципах служащих основой и в дальнейшем — было бы куда полезнее. И пусть даже в описании не на 100% раскрывались происходящие процессы — это вы могли бы постигнуть позже, уже понимая принципы в общих чертах.

          И понимать такой эксперимент было бы куда проще прочитав развернутое описание на родном языке, а не отрывочные ответы или куски чужого кода. Позже, уже разобравшись в основах — легко найти в интернете ответы на частные вопросы, но вот описания базы — часто не хватает.

          Да и основной посыл моего комментария был не в этом. Я уже ссылался на статью geektimes.ru/post/178747, и лучше чем ее автор вряд-ли смогу сказать.


  1. ViacheslavMezentsev
    27.01.2016 08:28

    Немного не понятно. Если я хочу послать серию байт и в этом же методе (потоке) получить ответ, то как будет выглядеть код? К примеру, я хочу реализовать одну из modbus-rtu функций для мастера.


  1. dim133
    27.01.2016 10:36
    +1

    Без этих пауз заставить Arduino среагировать не получилось.

    Открытие порта вызывает кратковременную установку высокого уровня DTR, который заведен на резет Ардуины. Отсюда затык в работе на время старта загрузчика.


  1. EngineerSpock
    27.01.2016 16:02

    Выбросьте стандартный говно-SerialPort из FCL. Напишите свой через WinAPI. Там только одной структурой манипулировать надо — DCB и пару функций протащить. Работать будет многократно быстрее и все слипы нелепые можно будет убрать.