Bluetooth ワイヤレス I/O (その1) 2019/07/02
エレファイン (ELEFINE) で販売されている 「BluetoothRelay2」 を使ってみました。
「BluetoothRelay2」 は、Bluetooth モジュール (RN42) と PIC (PIC18F14K50)、DC12V リレー駆動用のドライバ IC (ULN2003) で構成されています。
設定でいろいろな用途に使える入出力 GPIO (General Purpose Input/Output) 8 点+リレー出力 2 点が使えます。
GPIO は、あらかじめ割り付けするのではなく、送信したコマンドに応じて、DI (デジタル入力)、DO (デジタル出力)、ADC (アナログ入力) に変わります。
Bluetooth Classic なので、iOS での使用はできません。こちらでは、Android スマホで使用しています。
GPIO は 10kΩ 程度でプルアップしておく必要があります。USB 給電の時は、DC12Vリレーは使用できません。
動作電流は、DC12V 給電の場合で 通常 50mA 程度 + リレー駆動 35mA/1 個で、リレー 2 個 ON の場合は最大 110mA
でした。
市販の USB 5V->12V ケーブルでも使えそうです。
通信距離は、見通し ギリ 10m 程度。アンテナ廻りの基板、USB コネクタが影響するのか、他の RN42 使用のものより通信距離が若干も短い印象です。
■ Windows で使う
Windows でペアリングを行うと、仮想COMポートが2つ作成されますが、名前に 'SPP' がある発信側を使います。
↓の例では、名前が「RNBT-???? 'RNI-SPP'」の 「COM20:発信」がそれです。
デバイスマネージャーだと分かりにくいですね。
通信プロパティーを確認(変更)しておきます。
通信はごく一般的なシリアル通信です。コマンドを送信すると、必ず返信があります。
↓の例では、GPIO 1番めのデジタル入力 (DI 0) の状態を読み込んでいます。
'gpio read 0' + CR を送ると、'gpio read 0' + LF + CR + '0' + LF + CR + '>' が返ってきます。 '0' が 値 (LOW)、 '>' が終端です。
■ Android で使う
ペアリング済の Bluetooth 端末をリストアップします。名前が 「RNBT-」 で始まる RN42 を見つけて接続します。
あとは、SPP(Serial Port Profile) を使った通信で、コマンド送信、返信を受信します。
下記アプリでは、約 1 秒周期で DI 4 点、ADC 2 点、リレー 2 点の状態を取得しています。
■ダウンロード
BTGPIO.apk (Android サンプルアプリケーション apk 本体のみ)
※本アプリの著作権は作者 f.izawa が所有し、これを主張します。
※本アプリをインストール、使用したことによる事故、損害等の一切について作者はその責を負いません。ご自身の責任において使用してください。
■作者連絡先
e-mail : f.izawa@dream.com (@は小文字に)
URL : http://www.izawa-web.com/
// Android サンプル Delphi 10.2.3 unit Unit4; interface uses System.SysUtils, System.Types, System.UITypes, System.Classes, System.Variants, FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs, System.Bluetooth, System.Bluetooth.Components, FMX.ScrollBox, FMX.Memo, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Layouts, FMX.Edit, System.Rtti, FMX.Grid.Style, FMX.Grid,{ Math,} FMX.Objects, System.UIConsts, FMX.ListBox, System.IOUtils, System.IniFiles, // for TTS Androidapi.JNI.TTS,AndroidAPI.JNIBridge, AndroidApi.JNI.Media; type TBtThread = class(TThread) private { Private 宣言 } procedure BtOpen; protected procedure Execute; override; public constructor Create; virtual; end; type TForm4 = class(TForm) Timer1: TTimer; ScaledLayout1: TScaledLayout; Label7: TLabel; ComboBox3: TComboBox; Button2: TButton; Switch1: TSwitch; Rectangle8: TRectangle; Label6: TLabel; Rectangle14: TRectangle; Label13: TLabel; Rectangle15: TRectangle; Label20: TLabel; Label21: TLabel; Rectangle16: TRectangle; Rectangle17: TRectangle; Rectangle18: TRectangle; Rectangle19: TRectangle; Rectangle20: TRectangle; Rectangle21: TRectangle; Rectangle22: TRectangle; Label22: TLabel; Label23: TLabel; Label24: TLabel; Label25: TLabel; Label26: TLabel; Label27: TLabel; Label28: TLabel; Label29: TLabel; Rectangle1: TRectangle; CornerButton1: TCornerButton; CornerButton2: TCornerButton; CornerButton3: TCornerButton; CornerButton4: TCornerButton; Rectangle2: TRectangle; Label1: TLabel; Rectangle3: TRectangle; Label2: TLabel; CornerButton6: TCornerButton; CornerButton7: TCornerButton; CornerButton8: TCornerButton; Rectangle5: TRectangle; Rectangle6: TRectangle; Rectangle4: TRectangle; CornerButton5: TCornerButton; Rectangle7: TRectangle; Rectangle9: TRectangle; Rectangle10: TRectangle; Rectangle11: TRectangle; Rectangle12: TRectangle; Rectangle13: TRectangle; Rectangle23: TRectangle; Label3: TLabel; Rectangle24: TRectangle; Rectangle25: TRectangle; CornerButton9: TCornerButton; CornerButton10: TCornerButton; Rectangle26: TRectangle; Rectangle27: TRectangle; CornerButton11: TCornerButton; CornerButton12: TCornerButton; procedure Timer1Timer(Sender: TObject); procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); procedure Button2Click(Sender: TObject); procedure CornerButton1Click(Sender: TObject); procedure CornerButton5Click(Sender: TObject); procedure CornerButton9Click(Sender: TObject); procedure CornerButton11Click(Sender: TObject); // TTS type TttsOnInitListener = class(TJavaLocal, JTextToSpeech_OnInitListener) private [weak] FParent : TForm4; public constructor Create(AParent : TForm4); procedure onInit(status: Integer); cdecl; end; private { private 宣言 } ttsListener : TttsOnInitListener; tts : JTextToSpeech; procedure SpeakOut(const s :string); procedure InitTTS; public { public 宣言 } cmdMode, ioIndex : integer; decScale : Double; constructor Create(AOwner : TComponent); override; destructor Destroy; override; end; var Form4: TForm4; ADevice : TBluetoothDevice; ASocket : TBluetoothSocket; GThdMode : integer; GCmdMode : integer; ThBt : TBtThread; OpenNGcnt : integer; OpenMsecCnt : integer; Counter : integer; TTScnt : integer; BtDeviceHead : string; // uses ... Androidapi.JNIBridge, AndroidApi.JNI.Media; ToneGenerator: JToneGenerator; const // SPP(Serial Port Profile) による通信のUUID ServiceUUID = '{00001101-0000-1000-8000-00805F9B34FB}'; thdTHSTART = 1000; thdTHTERM = 2000; cmdSCCREATE = 200; cmdSCCONNECT = 201; cmdSCNG = 202; CRLF = #$0D#$0A; implementation uses Androidapi.JNI.JavaTypes, FMX.Helpers.Android {$IF CompilerVersion >= 27.0} , Androidapi.Helpers {$ENDIF} ; {$R *.fmx} // ----------------------------------------------------------------------------- // Bluetooth を Open し、接続する procedure TBtThread.BtOpen; var ABluetoothManager : TBluetoothManager; APairedDevices : TBluetoothDeviceList; ADevice : TBluetoothDevice; idx, i : integer; begin GThdMODE := thdTHSTART; try try ABluetoothManager := TBluetoothManager.Current; if ABluetoothManager.ConnectionState = TBluetoothConnectionState.Connected then begin // 過去にペアリングされたデバイスの一覧から、ターゲット を探す APairedDevices := ABluetoothManager.GetPairedDevices; if APairedDevices.Count > 0 then begin idx := -1; for i := 0 to APairedDevices.Count -1 do begin Synchronize(procedure() begin with Form4.ComboBox3 do begin BeginUpdate; Items.Add(APairedDevices[i].DeviceName ); EndUpdate; end; end); if (BTDeviceHead = APairedDevices[i].DeviceName) then begin Synchronize(procedure() begin with Form4.ComboBox3 do begin ItemIndex := i; end; end); idx := i; //break; // リストアップを続ける end; end; if idx >= 0 then begin ADevice := APairedDevices[idx]; if ADevice <> nil then begin ASocket := ADevice.CreateClientSocket(StringToGUID(ServiceUUID), False); if ASocket <> nil then begin GCMDMODE := cmdSCCREATE; // 接続 ASocket.Connect; if ASocket.Connected then GCMDMODE := cmdSCCONNECT; end; end; end; end; end; except on E : Exception do begin GCMDMODE := cmdSCNG; end; end; finally // 明示的にスレッドを終了(破棄される) // スレッド実行中にアプリを終了した時エラーになるため Terminate; WaitFor; FreeAndNil(ThBt); GThdMODE := thdTHTERM; end; end; constructor TBtThread.Create; begin // スレッドを生成、直ちに実行 inherited Create(False); // スレッド終了時、スレッドオブジェクトを破棄 FreeOnTerminate := True; end; procedure TBtThread.Execute; begin BtOpen; end; // ----------------------------------------------------------------------------- // テキストスピーチ procedure TForm4.InitTTS; begin tts := TJTextToSpeech.JavaClass.init(TAndroidHelper.Context, ttsListener); end; procedure TForm4.SpeakOut(const s : string); var text : JString; begin text := StringToJString(s); tts.speak(text, TJTextToSpeech.JavaClass.QUEUE_FLUSH, nil); end; { TForm4.TttsOnInitListener} constructor TForm4.TttsOnInitListener.Create(AParent: TForm4); begin inherited Create; FParent := AParent end; procedure TForm4.TttsOnInitListener.onInit(status: Integer); var Result : Integer; begin if (status = TJTextToSpeech.JavaClass.SUCCESS) then begin //result := FParent.tts.setLanguage(TJLocale.JavaClass.US); result := FParent.tts.setLanguage(TJLocale.JavaClass.JAPAN); if (result = TJTextToSpeech.JavaClass.LANG_MISSING_DATA) or (result = TJTextToSpeech.JavaClass.LANG_NOT_SUPPORTED) then ShowMessage('This Language is not supported'); end else ShowMessage('Initilization Failed!'); end; constructor TForm4.Create(AOwner: TComponent); begin inherited; ttsListener := TttsOnInitListener.Create(self); end; destructor TForm4.Destroy; begin if Assigned(tts) then begin tts.stop; tts.shutdown; tts := nil; end; end; // ----------------------------------------------------------------------------- function ASocketReceiveData(ASocket: TBluetoothSocket; ATimeout: Cardinal): string; // BT からの返信データ var AData : TBytes; ReadData : TBytes; i : integer; res : string; Ticks : Cardinal; idx : integer; loop : boolean; cnt : integer; begin res := ''; cnt := 0; SetLength(ReadData, 1024); idx := 0; Ticks := TThread.GetTickCount; loop := True; while loop and (cnt < 500) do begin Sleep(1); AData := ASocket.ReceiveData; if Length(AData) > 0 then begin for i := 0 to Length(AData) - 1 do begin ReadData[idx] := AData[i]; Inc(idx); // 終端まで読む #$3E = '>' if (AData[i] = $3E) {or (AData[i] = $03)} or (idx >= 1024) then begin loop := False; break; end; end; end; Inc(cnt); if loop then loop := TThread.GetTickCount - Ticks < ATimeout; end; SetLength(ReadData, idx); res := TEncoding.ANSI.GetString(ReadData); result := Trim(res); // 制御コードを含まない end; procedure TForm4.CornerButton11Click(Sender: TObject); // GPIO 4, 5 ON-OFF (1 パルス) // GPIO PIN No. 5, 6 が対象 var ATimeout : Cardinal; AData : TBytes; btn : TCornerButton; cmd, res : string; begin if (ASocket <> nil) and ASocket.Connected then begin Timer1.Enabled := False; ATimeout := 250; btn := Sender as TCornerButton; if btn = CornerButton11 then cmd := 'gpio set 4' else cmd := 'gpio set 5'; AData := TEncoding.ANSI.GetBytes(cmd + #13); // 送信 ASocket.SendData(AData); // 受信 res := ASocketReceiveData(ASocket, ATimeout); if Pos(cmd, res) = 1 then begin Sleep(250); if btn = CornerButton11 then cmd := 'gpio clear 4' else cmd := 'gpio clear 5'; AData := TEncoding.ANSI.GetBytes(cmd + #13); // 送信 ASocket.SendData(AData); // 受信 res := ASocketReceiveData(ASocket, ATimeout); if Switch1.IsChecked then begin // ブザー if Pos(cmd, res) = 1 then ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_ACK) else ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_NACK); end; end else if Switch1.IsChecked then ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_NACK); Timer1.Enabled := True; end; end; procedure TForm4.CornerButton1Click(Sender: TObject); // RELAY ON/OFF var AData : TBytes; res, cmd : string; ATimeout: Cardinal; btn : TCornerButton; begin if (ASocket <> nil) and ASocket.Connected then begin Timer1.Enabled := False; ATimeout := 250; btn := Sender as TCornerButton; if btn = CornerButton1 then cmd := 'relay on 0' else if btn = CornerButton2 then cmd := 'relay off 0' else if btn = CornerButton3 then cmd := 'relay on 1' else cmd := 'relay off 1'; AData := TEncoding.ANSI.GetBytes(cmd + #13); // 送信 ASocket.SendData(AData); // 受信 res := ASocketReceiveData(ASocket, ATimeout); if Switch1.IsChecked then begin // ブザー if Pos(cmd, res) = 1 then ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_ACK) else ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_NACK); end; Timer1.Enabled := True; end; end; procedure TForm4.CornerButton5Click(Sender: TObject); // GPIO 4, 5 ON-OFF // GPIO PIN No. 5, 6 が対象 var ATimeout : Cardinal; AData : TBytes; btn : TCornerButton; cmd, res : string; begin if (ASocket <> nil) and ASocket.Connected then begin Timer1.Enabled := False; ATimeout := 250; btn := Sender as TCornerButton; if btn = CornerButton5 then cmd := 'gpio set 4' else if btn = CornerButton6 then cmd := 'gpio clear 4' else if btn = CornerButton7 then cmd := 'gpio set 5' else cmd := 'gpio clear 5'; AData := TEncoding.ANSI.GetBytes(cmd + #13); // 送信 ASocket.SendData(AData); // 受信 res := ASocketReceiveData(ASocket, ATimeout); if Switch1.IsChecked then begin // ブザー if Pos(cmd, res) = 1 then ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_ACK) else ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_NACK); end; Timer1.Enabled := True; end; end; procedure TForm4.CornerButton9Click(Sender: TObject); // Relay ON/OFF(1 パルス) var AData : TBytes; res, cmd : string; ATimeout: Cardinal; btn : TCornerButton; begin if (ASocket <> nil) and ASocket.Connected then begin Timer1.Enabled := False; ATimeout := 250; btn := Sender as TCornerButton; if btn = CornerButton9 then cmd := 'relay on 0' else cmd := 'relay on 1'; AData := TEncoding.ANSI.GetBytes(cmd + #13); // 送信 ASocket.SendData(AData); // 受信 res := ASocketReceiveData(ASocket, ATimeout); if Pos(cmd, res) = 1 then begin Sleep(250); if btn = CornerButton9 then cmd := 'relay off 0' else cmd := 'relay off 1'; AData := TEncoding.ANSI.GetBytes(cmd + #13); // 送信 ASocket.SendData(AData); // 受信 res := ASocketReceiveData(ASocket, ATimeout); if Switch1.IsChecked then begin // ブザー if Pos(cmd, res) = 1 then ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_ACK) else ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_NACK); end; end else if Switch1.IsChecked then ToneGenerator.startTone(TJToneGenerator.JavaClass.TONE_PROP_NACK); Timer1.Enabled := True; end; end; procedure TForm4.Button2Click(Sender: TObject); // 接続先保存 var IniFile: TMemIniFile; begin IniFile := TMemIniFile.Create(System.IOUtils.TPath.Combine( System.IOUtils.TPath.GetDocumentsPath, 'btgpio.ini'), TEncoding.UTF8); with IniFile do begin try with ComboBox3 do begin if ItemIndex >= 0 then begin WriteString('Target', 'DeviceName', Items[ItemIndex]); ShowMessage('接続先: ' + Items[ItemIndex] + 'を保存しました.' + #13#10 + '次回起動時から有効になります.' + #13#10 + 'このアプリを再起動して下さい.'); end else ShowMessage('接続先が選択されていません.'); end; IniFile.UpdateFile; finally Free; end; end; end; procedure TForm4.FormCreate(Sender: TObject); var IniFile: TMemIniFile; // uses .... System.IniFiles; begin // 縦画面に固定 Application.FormFactor.Orientations := [TFormOrientation.Portrait, TFormOrientation.InvertedPortrait]; // use ..... System.IOUtils; IniFile := TMemIniFile.Create(System.IOUtils.TPath.Combine( System.IOUtils.TPath.GetDocumentsPath, 'btgpio.ini'), TEncoding.UTF8); with IniFile do begin try BtDeviceHead := ReadString('Target', 'DeviceName', 'EasyBT'); finally Free; end; end; // TTS InitTTS; // ブザー ToneGenerator := TJToneGenerator.JavaClass.init( TJAudioManager.JavaClass.STREAM_ALARM, TJToneGenerator.JavaClass.MAX_VOLUME); // Bruetooth スレッド Timer1.Interval := 10; Timer1.Enabled := True; ThBt := TBtThread.Create; end; procedure TForm4.FormDestroy(Sender: TObject); begin if ASocket <> nil then begin ASocket.Close; ASocket.Free; ASocket := nil; end; end; procedure TForm4.Timer1Timer(Sender: TObject); var ATimeout : Cardinal; AData : TBytes; i : integer; cmd, rcv : string; Ticks : Cardinal; comp : TComponent; lbl : TLabel; n, m : integer; v : integer; begin // BT 接続処理 if not ((GCMDMODE = cmdSCCONNECT) and ASocket.Connected) then begin Inc(OpenMsecCnt); Label7.Text := IntToStr(OpenMsecCnt * 10) + ' msec'; if GCMDMODE = cmdSCNG then begin Inc(OpenNgCnt); if OpenNgCnt > 4 then begin Timer1.Enabled := False; ShowMessage(BTDeviceHead + ' に、接続できません.'); end; end; if OpenMsecCnt > 200 then begin Timer1.Enabled := False; ShowMessage('接続先が無効です.'); end; end; // 通信処理 if (GCMDMODE = cmdSCCONNECT) and ASocket.Connected then begin Timer1.Interval := 250; Timer1.Enabled := False; Ticks := TThread.GetTickCount; ATimeout := 250; // RELAY 2 点の状態を取得 for i := 0 to 1 do begin cmd := 'relay read ' + i.ToString; AData := TEncoding.ANSI.GetBytes(cmd + #13); // 送信 ASocket.SendData(AData); // 受信 rcv := ASocketReceiveData(ASocket, ATimeout); rcv := StringReplace(rcv, #$0A#$0D, ';', [rfReplaceAll]); comp := FindComponent('Label'+IntToStr(28 + i)); if comp <> nil then begin lbl := TLabel(comp); if Pos(cmd, rcv) = 1 then begin if Pos('on', rcv) > 0 then lbl.TextSettings.FontColor := claRed else if Pos('off', rcv) > 0 then lbl.TextSettings.FontColor := claLime else lbl.TextSettings.FontColor := claYellow; end else lbl.TextSettings.FontColor := claWhite; end; end; // GPIO 0~3 の 4 点の状態を取得 // GPIO PIN No. の 1~4 // GPIO のピン番号 1~8 が、DI 0~7 に対応 for i := 0 to 3 do begin cmd := 'gpio read ' + i.ToString; AData := TEncoding.ANSI.GetBytes(cmd + #13); // 送信 ASocket.SendData(AData); // 受信 rcv := ASocketReceiveData(ASocket, ATimeout); rcv := StringReplace(rcv, #$0A#$0D, ';', [rfReplaceAll]); comp := FindComponent('Label'+IntToStr(22 + i)); if comp <> nil then begin lbl := TLabel(comp); if Pos(cmd, rcv) = 1 then begin // GPIO はプルアップしているため、Low(0) で ON if Copy(rcv, Length(rcv) - 2, 1) = '0' then lbl.TextSettings.FontColor := claRed else if Copy(rcv, Length(rcv) - 2, 1) = '1' then lbl.TextSettings.FontColor := claLime else lbl.TextSettings.FontColor := claYellow; end else lbl.TextSettings.FontColor := claWhite; end; end; // ADC 5,6 (= GPIO 6, 7) // GPIO PIN No. の 7, 8 // GPIO のピン番号 2~8 が、ADC 0~6 に対応 for i := 0 to 1 do begin cmd := 'adc read ' + (i + 5).ToString; AData := TEncoding.ANSI.GetBytes(cmd + #13); // 送信 ASocket.SendData(AData); // 受信 rcv := ASocketReceiveData(ASocket, ATimeout); rcv := StringReplace(rcv, #$0A#$0D, ';', [rfReplaceAll]); if Pos(cmd, rcv) = 1 then begin n := Pos(';', rcv); m := LastDelimiter(';', rcv); if (n > 0) and (m > n) then begin // 値 v := StrToIntDef(Copy(rcv, n+1, m-n-1), 0); // バーグラフ if i = 0 then begin Label20.Text := v.ToString; RectAngle5.Width := Trunc(205 * (v / 1023)); end else begin Label13.Text := v.ToString; RectAngle6.Width := Trunc(205 * (v / 1023)); end; end; end // 受信文字列が正常でない else begin if i = 0 then begin Label20.Text := ''; RectAngle5.Width := 0; end else begin Label13.Text := ''; RectAngle6.Width := 0; end; end; end; Label7.Text := IntToStr(TThread.GetTickCount - Ticks) + ' msec'; // テキストスピーチ(使用しない) { Inc(TTScnt); if TTScnt > 10 then begin TTScnt := 0; s := ''; if Switch1.IsChecked then s := s + 'PV ' + Label1.Text + #13; if Switch2.IsChecked then s := s + 'MFB ' + Label4.Text + ' '; if s <> '' then SpeakOut(s); end; } Timer1.Enabled := True; end; end; end.