2014年12月2日火曜日

C言語でArduinoとシリアル通信してアナログ電圧値をグラフ表示するWin32なC/C++のサンプルプログラムをつくる(1)

 こんにちは。タイトル長い。所用でとあるアナログデータをAD変換をしてPCに取り込み、グラフ表示するプログラムが必要になったので、”こんな感じでできます”サンプルとして、VisualStudioでWin32のプログラムを書いてゆきます。開発環境はWindows 7 32bit , Visual Studio 2012です。

 AD変換器として部屋に転がっていたArduinoUNOを使用しました。ArduinoUNOは10bitのADCを持っていますので、0-5Vのアナログ入力値を0-1023段階、4.9mV単位で量子化します。本当はもっと深いAD変換が必要だと思いますが、とりあえず簡易なデータロギング・表示のサンプルです。

 これまでC言語でGUIを持つプログラムはFormしか作ってこなかったので、かなり調べながらやりました。要素の配置を頭で想像しながらやるのはかなり大変でした。全ての処理を把握したいという要望があるみたいなので、Cで作れば文句ないと思います。

 簡単に処理の流れを書くと。。。
  1. ウィンドウ作成
  2. シリアルポート確立
  3. グラフグリッド描画
  4. グラフのリアルタイム描画用副スレッドの立ち上げ
  5. 副スレッドにてシリアルデータを読みながらプロットの描画
 と、こんな感じになっています。

プログラムを見ていく前にまず、プロジェクトの作成方法を確認します。

Win32GUIプログラムのプロジェクトの作成方法
 起動したらまず、新しいプロジェクトをクリックします。



 そうすると次のようなウィンドウが表示されますので、Visual C++ -> Win32 -> Win32プロジェクトの順で選択肢、適当な名前を入力してOKをクリックします。


すると、以下のようなウィザードが現れますが、特に変更するべきことはありませんので、完了をクリックしてプロジェクトの作成を完了します。


 プロジェクトの作成が完了しましたら、ビルドして実行をしてみます。ショートカットキーはF5です。できていれば何もない白いウィンドウが表示されるはずです。これが、Win32プログラムのスタートポイントです。


プログラム作成
 ではここからは作成したひな形に自分のプログラムを加えていきます。まずは、ウィンドウの大きさや名前を変更してみます。
BOOL InitInstance(HINSTANCE hInstance, int nCmdShow)
{
   HWND hWnd;
   DWORD dwID;
   
   hInst = hInstance; // グローバル変数にインスタンス処理を格納します。
   
   //Windowの大きさと名前の変更
   hWnd = CreateWindow(szWindowClass, L"GRAPHS", WS_OVERLAPPEDWINDOW,
      CW_USEDEFAULT, 0, MW_W, MW_H, NULL, NULL, hInstance, NULL);

   if (!hWnd)
   {
      return FALSE;
   }
   //Serialポートの確立
   setupSerial();
   ShowWindow(hWnd, nCmdShow);
   UpdateWindow(hWnd);

   //描画スレッドの作成
   CreateThread(NULL,0,threadfunc,(LPVOID)hWnd,0,&dwID);

   //ボタンの作成
   hButton1 = CreateWindow(L"BUTTON",L"計測開始",WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
    600,10,100,30,hWnd,(HMENU)ID_BUTTON1,hInstance,NULL);
   
   return TRUE;
}

 CreateWindow関数の2番目の引数を変更することで、ウィンドウの名前を変更することができます。また、6番、7番目の変数はそれぞれ、ウィンドウの横幅、縦幅となっています。
 その他の部分では、CreateThread関数を使って、描画用の副スレッドを作成しています。また、setupSerial関数はシリアルポートを確立し、Arduinoとシリアル通信を繋ぐ自作関数です。
 
では、setupSerial関数を見てみます。
void setupSerial(){
   //シリアル通信用ポートの確立 ("COM8")
   serialPort = CreateFile(L"COM8",GENERIC_WRITE|GENERIC_READ,0,NULL,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,NULL);
   if(serialPort == INVALID_HANDLE_VALUE)
   {
    OutputDebugString(L"PORT COULD NOT OPEN");
   }
   bool Ret = SetupComm(serialPort,1024,1024);
   if(!Ret){
    OutputDebugString(L"SET UP FAILED");
    CloseHandle(serialPort);
   }
   Ret = PurgeComm(serialPort,PURGE_TXABORT|PURGE_RXABORT|PURGE_TXCLEAR|PURGE_RXCLEAR);
   if(!Ret){
    OutputDebugString(L"CLEAR FAILED");
    CloseHandle(serialPort);
   }
   //基本通信条件の設定
   DCB dcb;
   GetCommState(serialPort,&dcb);
   dcb.DCBlength = sizeof(DCB);
   dcb.BaudRate = 9600;
   dcb.fBinary = FALSE;
   dcb.ByteSize = 8;
   dcb.fParity =NOPARITY;
   dcb.StopBits = ONESTOPBIT;

   Ret = SetCommState(serialPort,&dcb);

   if(!Ret){
    OutputDebugString(L"SetCommState FAILED");
    CloseHandle(serialPort);
   }
}

この関数は以前に書いたC言語でArduinoとシリアル通信してLEDを制御するの部分とほとんど同じです。

では次に、ウィンドウにグラフのグリッドを描画してみます。
//
//  関数: CreateGrids(HDC)
// 目的: グラフのグリッド描画を行います。
//
int CreateGrids(HDC hdc)
{
 HGDIOBJ hUserPen,hDefPen;

 //方眼の線色の設定
 hUserPen = CreatePen(PS_SOLID,1,RGB(200,200,200));
 hDefPen = SelectObject(hdc,hUserPen);

 //方眼の作成
 //x軸の描画
 for(int i=0; i<= (GW_y2-GW_y1)/grid_interval_x + (GW_x2-GW_y2)/grid_interval_x; i++)
 {
  WCHAR szBuffer[100];
  int current_x_posi = GW_x1+grid_interval_x*i;
  MoveToEx(hdc,current_x_posi,GW_y1,NULL);
  LineTo(hdc,current_x_posi,GW_y2);
  _stprintf_s(szBuffer,L"%d",grid_interval_x*i);
  TextOut(hdc,current_x_posi,GW_y2,szBuffer,wcslen(szBuffer));
 }
 //y軸の描画
 for(int i=0; i<=(GW_y2-GW_y1)/grid_interval_y; i++)
 {
  WCHAR szBuffer[100];
  int current_y_posi = GW_y2-grid_interval_y*i;
  MoveToEx(hdc,GW_x1,current_y_posi,NULL);
  LineTo(hdc,GW_x2,current_y_posi);
  _stprintf_s(szBuffer,L"%dV",i);
  TextOut(hdc,GW_x1-20,current_y_posi,szBuffer,wcslen(szBuffer)); 
   
 }
 
 return 0;
}

 このようにして方眼を作成します。線を引くためにはまず始点をMoveToEx関数で指定して、そこからLineTo関数を使って線を描画します。また、軸に数値を表示するためにはまず、数値を文字列に変換するために、_stprintf_s関数を用いてからTextOut関数で描画します。

次に副スレッドでArduinoからデータを取得して、リアルタイムにプロットを描画していきます。
DWORD WINAPI threadfunc(LPVOID vdParam)
{
 HDC hdc;
 unsigned int iCount = 0;
 char data[255]="0"; 
 double ch1Value = 0.0;
 double ch2Value = 0.0;
 char* ch1Data = {0};
 char* ch2Data = {0};
 char* ctx = {0};
 HGDIOBJ hUserPen,hUserBrush,hDefPen,fDefBrush,hNullPen,hNullBrush;

 while(1)
 {
  hdc = GetDC((HWND)vdParam);
  //横軸のポイント数のカウントアップ
  iCount+=1;
  
  //現在取得した点(LineToの終点)
  nPointch1x =  GW_x1+iCount;
  nPointch1y = GW_y2-ch1Value*100;
  nPointch2x =  GW_x1+iCount;
  nPointch2y = GW_y2-ch2Value*100;
  //ch1の線色を指定して描画
  hUserPen = CreatePen(PS_SOLID,1,RGB(255,0,0));
  hDefPen = SelectObject(hdc,hUserPen);
  MoveToEx(hdc,nOldPointch1x,nOldPointch1y,NULL);
  LineTo(hdc,nPointch2x,nPointch1y);
  SelectObject(hdc,hDefPen);
  //ch2の線色を指定して描画
  hUserPen = CreatePen(PS_SOLID,1,RGB(0,0,255));
  hDefPen = SelectObject(hdc,hUserPen);
  MoveToEx(hdc,nOldPointch1x,nOldPointch2y,NULL);
  LineTo(hdc,nPointch2x,nPointch2y);
  SelectObject(hdc,hDefPen);

  //計測点のマーカの描画、marker_sizeを0にすると表示しない。
  if(marker_size != 0)
  {
   Rectangle(hdc,nPointch1x+marker_size,GW_y2-ch1Value*100-marker_size,nPointch1x-marker_size,GW_y2-ch1Value*100+marker_size);
   Rectangle(hdc,nPointch2x+marker_size,GW_y2-ch2Value*100-marker_size,nPointch2x-marker_size,GW_y2-ch2Value*100+marker_size);
  }
  
  //現在取得した点を次のLineTo始点にする。
  nOldPointch1x = nPointch1x;
  nOldPointch1y = nPointch1y;
  nOldPointch2x = nPointch2x;
  nOldPointch2y = nPointch2y;

  DWORD dwRead;
  DWORD dwSendSize;
  BYTE sendflag = 1;
  DWORD dwErrorMask;
  COMSTAT comStat;
  DWORD dwCount;
  TCHAR tdata[255];

  ClearCommError(serialPort,&dwErrorMask,&comStat);
  dwCount = comStat.cbInQue;
  WriteFile(serialPort,&sendflag,sizeof(sendflag),&dwSendSize,NULL);
  bool Ret = ReadFile(serialPort,&data,dwCount,&dwRead,NULL);
  if(!Ret){
   OutputDebugString(L"thread READ FAILED\n");
   CloseHandle(serialPort);
  }
  //シリアルデータが文字列として送信されてくるので区切る
  ch1Data = strtok_s(data,",\r\n",&ctx);
  ch2Data = strtok_s(null_ptr,",\r\n",&ctx);
  //ch2DataのNULLによるエラー防止処理
  if(ch2Data==NULL)ch2Data="9.9";
  //10bitデータを0~5Vに変換する。刻みはおよそ4.9mVとなる。
  ch1Value = map(atoi(ch1Data),0.0,1023.0,0.0,5.0);
  ch2Value = map(atoi(ch2Data),0.0,1023.0,0.0,5.0);
  //取得したデータを電圧に変換して表示
  _stprintf_s(tdata,L"ch1:%1.5lf     ch2:%1.5lf",ch1Value,ch2Value);
  TextOut(hdc,20,20,tdata,wcslen(tdata));
  //サンプリング間隔(ミリ秒)※要改善点※
  Sleep(1000);
 }
}

 副スレッドでデータを取得する部分は無限ループを一定間隔で回すことでリアルタイム描画を実現します。2ch分のデータを取得するのでArduinoからのデータは文字列にて送信し、C言語のプログラム側でカンマで分割処理することで2ch分のデータを同時に取得します。また、Arduinoからデータを送ってもらうタイミングを同期するためにC言語側から適当なデータをArduinoに送信し、そのタイミングでArduinoからデータを送信してもらうようにしています。

Arduino側のプログラムは
//logger 2ch
void setup() {
  // initialize the serial communication:
  Serial.begin(9600);
}

void loop() {
  // send the value of analog input:
  if(Serial.available()>0){
    Serial.print(analogRead(A0));
    Serial.print(",");
    Serial.println(analogRead(A1));
    Serial.read();
  }
}
以上です。

動作している様子は以下のようになっています。

_kiitaniさん(@monitorgazer)が投稿した動画 -

今回のプログラムのVisualStudioのプロジェクトファイルは以下からダウンロード可能です。

https://app.box.com/s/bw4gvmaqd7nlihvjaouj

改善が必要な部分
 現在のプログラムでは、軸の拡大縮小・移動が実装されていません。また、表示可能な部分が限られていますので、グラフ領域の端まできたら表示部位を切り替えてプロットしていくようなアルゴリズムを考える必要があります。また、データを保存する機能なども必要だろうと思います。これから改善してゆきます。

2 件のコメント:

  1. こんにちは。今やろうとしていることに近いことをされているので、とても参考になりました。もう少し詳しく勉強してみたいと思うので、もしよろしければ参考にされたサイトや書籍などを教えていただけないでしょうか?

    返信削除
    返信
    1. こんにちは。Win32APIを使用したGUIソフトウエアの開発についてはhttp://wisdom.sakura.ne.jp/system/winapi/win32/index.htmlを参考にしました。また、Arduinoに関しては“Prototyping Lab ―「作りながら考える」ためのArduino実践レシピ:小林茂 著”が参考になると思います。
      現在はGUIアプリケーションの作成にQtというクラスプラットフォームなC/C++のGUIツールキットを使用しています。これはもちろんVisualStudioで使用できます。 こちらのツールキットを使うことでWin32APIよりも簡単にGUIアプリを作ることができます。参考サイトはhttp://densan-labs.net/tech/qt/index.htmlなどでしょうか。やりたいことに応じて検索すると色々な情報が得られると思います。

      削除