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.