はじめに
リコーの @KA-2 です。
THETAプラグインをご存じない方はこちらをご覧ください。興味を持たれた方はTwitterのフォローと
THETAプラグイン開発コミュニティ(Slack)への参加もよろしくおねがいします。
この記事では、THETA V のUSB Host機能の中から、シリアル通信を行う方法について紹介します。
Windowsの世界では「仮想COM」などと呼ばれている通信方法です。
THETA Vが親となり、接続した機器に給電し双方向通信が行えるようになるわけですが、今回は、GPS/GNSSレシーバーがTHETA V側へ定期的に送信する衛星測位結果を受信しTHETA V内部に反映させるという、受信(read)のみで完結する事例を紹介します。送信(write)も含む他の事例なども追々増やしていく予定です。
なお、現在公開されいている「ボタンを押すと撮影する」というTHETAプラグインSDKに、この振る舞いを追加するだけでは少々物足りないので、インターバル撮影(撮影間隔はTHETA任せの最短、撮影枚数は無制限=停止操作をするまで、に諸設定を固定)"も"行うことができるプラグインとしました。
こうすることで、自分だけのストリートビュー用データを、スマートフォンなしで簡単に撮影できるようになります。撮り貯めたデータは「ジオタグ対応のビュワーで見るだけ」で以下のような表示ができます。画像データ群から地図に移動軌跡が描け、写真が対応づいているという状態です。
ただし、1点おおきな注意点(未解決事項)が・・・
Androidの"お約束"に「アプリケーションにUSB機器を扱うためのパーミッションを与えるには、ダイアログでユーザーに問い合わせること」というのがあるのですが、
本記事執筆時点では、「開発者モードにしたTHETA VでVysorを使わないと、USB機器を扱うプラグインにパーミッションを与えられない(画面がないので出されたダイアログにOKというお返事ができない…)」という状態です。
このため、「THETA Vを開発者モードにした人だけが楽しめる技術情報の紹介」となります。
一般の方々でもプラグインをインストールするだけで使えるようにするための方法を調査・検討中です。
(細かいことはわからないが急いで使ってみたいという方は、THETA Vを開発者モードにして、月1回程度開催している「THETAプラグイン開発もくもく会」にいらっしゃると、お試し利用設定して貰えるカモしれません。参加表明時にコメントいただくか、THETAプラグイン開発コミュニティ(Slack)などで事前にご連絡ください。ただし、必要なハードウェアはご自身で負担いただく必要があります。なにか問題が起こっても自己責任となります。)
衛星測位用語や基礎知識について
日本では「衛星測位=GPS」のような扱いが日常的になされているとおもいます。しかし、各国の測位衛星が打ち上げられ、それらを利用できる現在では、「GNSS」と呼ぶべきです。手短で判りやすい説明は国土地理院のページをご参照ください。
この記事では「衛星測位」のことを「GPS/GNSS」とし、二つの言葉を併記させていただきます。
大抵のGPS/GNSSレシーバーは「NMEA 0183」という形式のデータをシリアル通信で受信側へ送りつける形式です。THETA内部に位置情報を設定する際、このデータ形式についていくらかのこと知っておく必要がありますが、ファイル保存をしたらテキストエディタでも読めるASCII文字列の羅列で、その詳細も検索すると容易に情報が得られますので説明は割愛させて頂きます。
少々余談
今回使用するレシーバーでは「みちびき(QZSS)」の電波も受信できるのですが・・・ 「みちびき(QZSS)」の精度に関して、衛星からの電波を受信するだけでcm級の精度や数m級の精度が出ると誤解されている方が多いと思われるため、よくまとめられている[こちら](http://www.ne.jp/asahi/nature/kuro/HGPS/principle_gps.htm?fbclid=IwAR09kbcmA2pHFFlb1pidXvwQi_gBNPLRmXuCsUbNTH0TxwdnR1VvPqZXJgc)をご参照ください。 乱暴めに要約すると“GPSだけでも「サブメータ級」と呼ばれる精度が場所と時間帯(衛星の見え方)によっては出るわけですが、QZSSなども同時に受信できることにり、ビルの谷間などでもアタリが出やすくなる。” くらいが衛星からの電波だけで実現できることの限界です。より安定させたり精度が欲しい場合には地上基地局からの補正情報も受信できる必要があります。必要なハードウェア
必要なハードウェアは以下2つとなります。
GPS/GNSSレシーバーの選定について
今回使用した品物は「VK172 G-MOUSE USB GPS/GLONASS USB GPSレシーバー」です。同一の品物を販売している会社が複数あります。真のメーカーは不明です。
手ごろな価格かつ小型の組み合わせにしたかったので「USBドングルタイプ」にこだわって探してみたところ、現時点、日本で入手性がよいものはこの商品だけでした。
- PCやタブレットのUSBポートに差し込んで使える
- USBのデバイスクラスがCDC or 独自クラスに見えてもFTDI/Prolific/Silicon labsのICを使っている(= 以降で説明するUSB Serialライブラリが対応している)
- NMEA 0183形式のデータを出力する
ということを満たしているのであれば、バッテリ&記録装置内蔵タイプのロガーなどの選択肢もあると思われますし、タブレット等をカーナビ化するためのケーブルが長い受信機でも動作すると思われます。
ケースがない等の条件を気にしないのであれば、電子工作用のレシーバーも良いと思います。
「VK172 G-MOUSE USB GPS/GLONASS USB GPSレシーバー」について
電源をホストからもらい動作するレシーバーです。PCやタブレットで使われている方が多いようです。
衛星からの測位電波が受信できるとレシーバーのLEDが点滅しはじめます。このとき、必ずしも測位できているわけではありません。広い場所ならLEDが点滅すると大抵測位もできているのですが、稀にズレが生じるため記しておきます。
この商品は、スマートフォンなど小型機器に搭載されるレシーバーで有名なu-blox社の受信ICの中でも、 UBX-G7020という少し古めの受信ICを利用したもののようです。
GPS/QZSS系(米/日)とGLONASS系(露)では、使用している周波数帯が異なるのですが、このレシーバーは2系統の同時受信はおこなえません。
出荷状態において、GPS/QZSS系の受信が行え、位置情報の更新頻度は1秒間隔、シリアル通信のボーレートは 9600bpsとなっています。
u-blox社が無償配布しているパソコン用ソフトウェア「u-center」にて、上記の出荷状態を含む多様な事項を細かに操れるようですが、今回は出荷状態に特化したプラグインを作成しています。
その他のコーディングに必要な情報は、下準備の章で調査の仕方と共に紹介します。
実装に向けた下準備
wifi接続でVysorを使う方法
THETAのUSBポートに利用したい機器を接続してしまうと、有線でのVysor利用ができなくなってしまいます。これをリカバーするために wifiを利用したVysorの使い方を記しておきます。
(1) USBケーブルでPCとTHETA Vを接続
(2) ターミナルから「adb tcpip 5555」を打つ
(3) Wi-Fi でPCとTHETA Vを接続(THETA VはAPモードでOK)
(4)ターミナルから「adb connect 192.168.1.1:5555」を打つ
(5) VysorからTCPのほうで接続
(6) USBケーブルをはずしてもVysorが使えることを確認
※(2)と(3)は逆順でもOKです
※(5)と(6)は逆順でもOKです
シリアル通信するハードウェアの動作確認
作業をはじめる前に、利用しようとしている機器とTHETA Vの間でUSBシリアル通信を行うことができるかどうかを、Androidスマートフォン用のアプリケーションで調べることができます。
今回私が利用したのは「Serial USB Terminal」というアプリケーションです。
前述のwifiでVysorを使う諸手順を踏んだあと、THETA Vに機器を接続し、Vysorからアプリを起動して動作確認をしてください。今回の機器はボーレートが事前にわかっていましたので9600bpsと設定した後動作確認をしました。接続したGPS/GNSSレシーバーからNMEA 0183形式のデータを受信できることが判りました。
使用するUSB機器の USBデバイスクラス、ベンダID、プロダクトID を調べる
パソコンなどでも調べることができますが、Androidスマートフォン用のアプリケーション「USB Device Info」を使うとTHETA Vでも確認ができます。
今回利用するGPS/GNSSレシーバーのUSBに関する情報を確認した例が以下です。
USBデバイスクラス = CDC(Communication Device Class = 0x02)
ベンダーID = 0x1546 (=5446 d)
プロダクトID = 0x01A7 (=423 d)
であることが読み取れると思います。
これらはコーディングの際に必要となります。
Android用シリアル通信ライブラリ導入方法
AndroidスマートフォンやタブレットでUSB Host経由でシリアル通信を行う場合、「usb-serial-for-android」というライブラリを利用するのが一般的なようです。
CDCクラスのUSBデバイスの他に、電子工作の世界では一般的な FTDI/Prolific/Silicon labsのICも一通りサポートされています。今回はこちらのライブラリをTHETAプラグイン開発に利用してみました。
大枠の手順としては、
- ライブラリファイル(aarファイル)を作る
- 自身のプロジェクトにライブラリを取り込む
となります。
なお、今回作成したプラグインのファイル一式には、上記作業手順を踏み作成したaarファイルも含んでおります。
当面はこのaarファイルを利用できるとは思うのですが、ご自身のAndroid Studioビルド環境が変わった際には、ご自身でビルドしたaarファイルを使用する必要が生じますのでご注意ください。
以降にそれぞれについて説明します。
ライブラリファイル(aarファイル)を作る
こちらからサンプルアプリケーションを含むプロジェクトファイル一式ダウンロードし、ビルドをします。
AndroidStudioでは、aarファイルだけを生成することができないため一旦サンプルアプリケーションをビルドした後、ツリーにある「usbSerialForAndroid」を選択し、メニューの Build -> Make Module 'usbSerialForAndroid' を実行することでaarファイルを生成します。
生成されたaarファイルは、「~\usb-serial-for-android-master\usbSerialForAndroid\build\outputs\aar」配下にあります。
ライブラリのプロジェクトファイルを読み込んだり、ビルドをする際、多量のエラーが発生します。
これは、ライブラリ自体が古く更新も止まっているため、AndroidStudioに関する環境設定を見直す必要があるためです。
数ヵ月後には使えなくなってしまうノウハウと思われること、エラーメッセージを検索エンジンでしらべ解決することを繰り返すと、もともとAndroidアプリケーション開発経験すらなかった私でもクリーンビルドまで辿り着けたこと(近場にいるベテランのアドバイスを求めたりもしましたが^^;)から、詳細は割愛させて頂きます。私にとっては、ここが一番の難関でした。。。
自身のプロジェクトにライブラリ(aarファイル)を取り込む
ベースとしたプロジェクトはTHETA plugin SDKです。
Vysorで見た時のプラグイン表示名(アイコンと共に表示される名称)、プラグインのパッケージ名、プラグインのバージョンを独自の設定としていますが、Androidアプリケーションと同様の作業のため記載は割愛します。
libsフォルダの作成しaarファイルを置く
プロジェクトのルートフォルダ -> app 配下に「libs」というフォルダを作成し、前述のaarファイルをおきます。
build.gradle(Module: app)の編集
以下の記述を追加します
repositories {
flatDir {
dirs 'libs'
}
}
dependencies {
implementation(name:'aarファイル名(拡張子を除く)', ext:'aar') //add
:略
}
AndroidManifest.xmlとdevice_filter.xmlへの追記
「はじめに」の末尾にて「1点おおきな注意点(未解決事項)が・・・」とした事項が解決すると、この記述は不要となります。現時点では必要な事項ですので記しておきます。
こちらはUSB機器とプラグイン(スマートフォンならアプリ)の紐付けをAndroid OSに記憶させるための記述です。この記述があると、USB機器を差し込んだ際 Android OS は該当するプラグインを自動的に起動します(OTG:On The Goの機能が働きます)。
この振る舞いは、Modeボタンの長押しでプラグインの起動/終了を行わせるTHETAプラグインのお作法からははずれていますが、意図的にさせています。これは、今時点、OTGでプラグインを起動させたときに表示されるダイアログでないと長期間にわたり有効なパーミッションを与えることができなかったためです。
<intent-filter>
:略
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
device_filter.xmlはgitHubからダウンロードしたライブラリのファイル群の中にあります。
これを自身のプロジェクトの
app -> src -> main -> res -> xml フォルダ配下に置き、新たに利用するデバイスの定義を追記します。
下準備で調べておいたベンダIDとプロダクトIDの10進数の値が必要です。
<resources>
<!-- 0x1546 / 0x01a7: u-blox7 GPS/GNSS Reciver -->
<usb-device vendor-id="5446" product-id="423" />
:略
</resources>
プロジェクトを開きなおしビルドを通す
ここまでの手順を踏んだあと、一度プロジェクトを開きなおしてください。
その後、以下の import を削除orコメントアウトするとビルドが通るようになると思われます。
import com.theta360.pluginapplication.R;
実装したソースコードの説明
今回作成したプラグインのファイル一式をこちらにおいておきます。
ベースとしたTHETAプラグインSDKにあるファイルの中でも、HttpConnector.java、MainActivity.javaの2つのファイルを触りました。
以降でそれぞれのファイルの中で追加修正したコードをブロックに分けて説明しておきます。
HttpConnector.java
プラグインからwebAPIを実行する処理がまとめられています。部品群のようなものです。
webAPIは外部からTHETAを操るだけでなく、プラグインからも実行可能です。
コマンドの仕様についてはこちらをご参照ください。
今回のプラグインを作るために必要なコマンド実行部を追加しました。
もっと丁寧にプログラムの構造を見直したほうが良いのですが、、、
今回は急ぎでコピペ&微修正で新たな処理を追加しており、重複したコードが多いです。
測位結果を設定するためのコード
/**
* Set GPS(GNSS) Info
*
* @return Error message (null is returned if successful)
*/
public String setGpsInfo(String lat, String lng, String height, String dateTimeZone) {
HttpURLConnection postConnection = createHttpConnection("POST", "/osc/commands/execute");
JSONObject input = new JSONObject();
String responseData;
String errorMessage = null;
InputStream is = null;
try {
// send HTTP POST
input.put("name", "camera.setOptions");
JSONObject parameters = new JSONObject();
JSONObject options = new JSONObject();
JSONObject gpsInfo = new JSONObject();
gpsInfo.put("lat",lat);
gpsInfo.put("lng",lng);
gpsInfo.put("_altitude",height);
gpsInfo.put("_dateTimeZone",dateTimeZone);
gpsInfo.put("_datum","WGS84");
options.put("gpsInfo", gpsInfo);
parameters.put("options", options);
input.put("parameters", parameters);
OutputStream os = postConnection.getOutputStream();
os.write(input.toString().getBytes());
postConnection.connect();
os.flush();
os.close();
is = postConnection.getInputStream();
responseData = InputStreamToString(is);
// parse JSON data
JSONObject output = new JSONObject(responseData);
String status = output.getString("state");
if (status.equals("error")) {
JSONObject errors = output.getJSONObject("error");
errorMessage = errors.getString("message");
}
} catch (IOException e) {
e.printStackTrace();
errorMessage = e.toString();
InputStream es = postConnection.getErrorStream();
try {
if (es != null) {
String errorData = InputStreamToString(es);
JSONObject output = new JSONObject(errorData);
JSONObject errors = output.getJSONObject("error");
errorMessage = errors.getString("message");
}
} catch (IOException e1) {
e1.printStackTrace();
} catch (JSONException e1) {
e1.printStackTrace();
} finally {
if (es != null) {
try {
es.close();
} catch (IOException e1) {
e1.printStackTrace();
}
}
}
} catch (JSONException e) {
e.printStackTrace();
errorMessage = e.toString();
} finally {
if (is != null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return errorMessage;
}
※以降はJSONObjectの処理を記載し、Exception系の処理は省略いたします。
インターバル撮影に関する設定を実行するコード
/**
* Set Interval Parameters
*
* @return Error message (null is returned if successful)
*/
public String setIntervalParam(int capInterval, int capNumber) {
HttpURLConnection postConnection = createHttpConnection("POST", "/osc/commands/execute");
JSONObject input = new JSONObject();
String responseData;
String errorMessage = null;
InputStream is = null;
try {
// send HTTP POST
input.put("name", "camera.setOptions");
JSONObject parameters = new JSONObject();
JSONObject options = new JSONObject();
options.put("captureInterval", String.valueOf(capInterval));
options.put("captureNumber", String.valueOf(capNumber));
parameters.put("options", options);
input.put("parameters", parameters);
OutputStream os = postConnection.getOutputStream();
os.write(input.toString().getBytes());
postConnection.connect();
os.flush();
os.close();
is = postConnection.getInputStream();
responseData = InputStreamToString(is);
// parse JSON data
JSONObject output = new JSONObject(responseData);
String status = output.getString("state");
if (status.equals("error")) {
JSONObject errors = output.getJSONObject("error");
errorMessage = errors.getString("message");
}
} catch (IOException e) {
:略
}
return errorMessage;
}
インターバル撮影開始を実行するコード
/**
* startCapture
*
* @return Error message (null is returned if successful)
*/
public String startCapture(String mode) {
HttpURLConnection postConnection = createHttpConnection("POST", "/osc/commands/execute");
JSONObject input = new JSONObject();
String responseData;
String errorMessage = null;
InputStream is = null;
try {
// send HTTP POST
input.put("name", "camera.startCapture");
JSONObject parameters = new JSONObject();
parameters.put("_mode", mode);
input.put("parameters", parameters);
OutputStream os = postConnection.getOutputStream();
os.write(input.toString().getBytes());
postConnection.connect();
os.flush();
os.close();
is = postConnection.getInputStream();
responseData = InputStreamToString(is);
// parse JSON data
JSONObject output = new JSONObject(responseData);
String status = output.getString("state");
if (status.equals("error")) {
JSONObject errors = output.getJSONObject("error");
errorMessage = errors.getString("message");
}
} catch (IOException e) {
:略
}
return errorMessage;
}
インターバル撮影停止を実行するコード
/**
* stopCapture
*
* @return Error message (null is returned if successful)
*/
public String stopCapture() {
HttpURLConnection postConnection = createHttpConnection("POST", "/osc/commands/execute");
JSONObject input = new JSONObject();
String responseData;
String errorMessage = null;
InputStream is = null;
try {
// send HTTP POST
input.put("name", "camera.stopCapture");
OutputStream os = postConnection.getOutputStream();
os.write(input.toString().getBytes());
postConnection.connect();
os.flush();
os.close();
is = postConnection.getInputStream();
responseData = InputStreamToString(is);
// parse JSON data
JSONObject output = new JSONObject(responseData);
String status = output.getString("state");
if (status.equals("error")) {
JSONObject errors = output.getJSONObject("error");
errorMessage = errors.getString("message");
}
} catch (IOException e) {
:略
}
return errorMessage;
}
インターバル撮影の状態をチェックするためのコード
/**
* Acquire device status
*
* @return _captureStatus String
*/
public String getCaptureStatus() {
HttpURLConnection postConnection = createHttpConnection("POST", "/osc/state");
String responseData;
String capStat = "";
InputStream is = null;
try {
// send HTTP POST
postConnection.connect();
is = postConnection.getInputStream();
responseData = InputStreamToString(is);
// parse JSON data
JSONObject output = new JSONObject(responseData);
mFingerPrint = output.getString("fingerprint");
JSONObject status = output.getJSONObject("state");
capStat = status.getString("_captureStatus");
} catch (IOException e) {
:略(前述の処理と少々異なります。プロジェクト一式をダウンロードしてご確認ください)
}
return capStat;
MainActivity.java
処理の本体はこちらとなります。
MainActivityはスマートフォン用アプリケーションにおける画面のようなものです。
部材操作のイベントを受けると処理を振り分けるわけですが、直接シリアル通信を行ったりHTTP通信を行うことが出来ません。
今回はプラグイン起動時 (厳密にはonResume時)にシリアル通信用のスレッドをわけるようにしました。
スレッドでは、GPS/GNSSレシーバーからの受信をポーリングし、THETA内部の位置情報を更新するためのデータが揃うと、データを編集して内部への設定までを行います。
また、少々後づけ感があるのですが、部材操作受付時にバッファしておいたインターバル撮影の開始/停止処理なども行っています。
ベースとしたSDKに記述済みのtakePictureを実行するタスクの処理だけが浮いてしまうのですが、内部へのwebAPIコマンド送信部は1つのスレッド/タスクにまとめるほうが安全です。
プログラム構造の話からはそれますが、おまけ機能として、GPS/GNSSレシーバーから受け取ったNMEA 0183形式の生データをファイル保存しておく処理も記述してあります。こうすると、THETA Vが「撮影しなくても機能するGPS/GNSSロガー」になるためです。デバッグにも便利でした。
変数の定義
モロモロの処理に必要な変数をMainActivityの先頭付近に並べてあります。
//シリアル通信関連
private boolean mFinished;
private UsbSerialPort port ;
//USBデバイスへのパーミッション付与関連
PendingIntent mPermissionIntent;
private static final String ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION";
//インターバル撮影関連
private boolean isIntervalMode= false;
private boolean isIntervalStat = false;
private boolean sendReq = false;
操作受付に関する処理(onKeyDown(), onKeyUp())
通常撮影/インターバル撮影の切り替え操作やインターバル撮影の開始/停止操作を受け付けるためにonKeyDown()を以下のように修正しています。
@Override
public void onKeyDown(int keyCode, KeyEvent event) {
//--------------- customized code ---------------
if (keyCode == KeyReceiver.KEYCODE_CAMERA) {
/*
* To take a static picture, use the takePicture method.
* You can receive a fileUrl of the static picture in the callback.
*/
if (isIntervalMode){
if (isIntervalStat) {
isIntervalStat =false;
} else {
isIntervalStat =true;
}
sendReq = true;
} else {
new TakePictureTask(mTakePictureTaskCallback).execute();
}
} else if (keyCode == KeyReceiver.KEYCODE_MEDIA_RECORD) {
if (isIntervalMode) {
if (isIntervalStat) {
isIntervalStat =false;
sendReq = true;
}
isIntervalMode = false;
notificationLedHide(LedTarget.LED7);
} else {
isIntervalMode = true;
notificationLedShow(LedTarget.LED7);
}
}
//-----------------------------------------
}
onKeyUp()は無処理としました。
@Override
public void onKeyUp(int keyCode, KeyEvent event) {
/**
* You can control the LED of the camera.
* It is possible to change the way of lighting, the cycle of blinking, the color of light emission.
* Light emitting color can be changed only LED3.
*/
//SDKにかかれてたコードが不要なので消しておく。
//notificationLedBlink(LedTarget.LED3, LedColor.BLUE, 1000);
}
onResume()の処理
onResume()にシリアル通信の初期化にまつわる処理を記述しています。
冒頭のUSBシリアル通信初期化に関する以下3行は、下調べしておいた「ベンダID、プロダクトID、USBデバイスクラスに該当する処理」を引数から与えデバイスリストに追加したあと、そのリストを利用しています。
final ProbeTable probeTable = UsbSerialProber.getDefaultProbeTable();
probeTable.addProduct(0x1546,0x01a7,CdcAcmSerialDriver.class);
List<UsbSerialDriver> usb = new UsbSerialProber(probeTable).findAllDrivers(manager);
ライブラリ「usb-serial-for-android」のREADME.md 「4. Use it! Example code snippet:」に記載してある以下コードを修正したものです。
List<UsbSerialDriver> usb = UsbSerialProber.getDefaultProber().findAllDrivers(manager);
こうすることで、予めライブラリに定義されてないデバイスを使用する際、ライブラリ側のソースコードに手を入れる必要がなくなります。
ライブラリ自体に手を入れる方法は、こちらのサイトなどをご参考としてください。
シリアル通信のボーレート設定は、onResume()末尾付近の以下行でしています。
GPS/GNSSレシーバのボーレート設定を変えたり、他のシリアル通信機器で異なるボーレートを利用する際には、機器に合った数値を記載してください。
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);
以下、 onResume()のコード全体を掲載しておきます。
@Override
protected void onResume() {
Log.d("GNSS", "M:onResume()");
//--------------- added code ---------------
mFinished = true;
// Find all available drivers from attached devices.
UsbManager manager = (UsbManager) getSystemService(Context.USB_SERVICE);
//List<UsbSerialDriver> usb = UsbSerialProber.getDefaultProber().findAllDrivers(manager);
final ProbeTable probeTable = UsbSerialProber.getDefaultProbeTable();
probeTable.addProduct(0x1546,0x01a7,CdcAcmSerialDriver.class);
List<UsbSerialDriver> usb = new UsbSerialProber(probeTable).findAllDrivers(manager);
// デバッグのため認識したデバイス数をしらべておく
int usbNum = usb.size();
Log.d("GNSS","usb num =" + usbNum );
if (usb.isEmpty()) {
Log.d("GNSS","usb device is not connect." );
notificationLedBlink(LedTarget.LED3, LedColor.RED, 1000);
//return;
//port = null;
} else {
// Open a connection to the first available driver.
UsbSerialDriver driver = usb.get(0);
//USBデバイスへのパーミッション付与用(機器を刺したときスルーしてもアプリ起動時にチャンスを与えるだけ。なくても良い。)
mPermissionIntent = PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), 0);
manager.requestPermission( driver.getDevice() , mPermissionIntent);
UsbDeviceConnection connection = manager.openDevice(driver.getDevice());
if (connection == null) {
// You probably need to call UsbManager.requestPermission(driver.getDevice(), ..)
// パーミッションを与えた後でも、USB機器が接続されたままの電源Off->On だとnullになる... 刺しなおせばOK
notificationLedBlink(LedTarget.LED3, LedColor.YELLOW, 500);
Log.d("GNSS","M:Can't open usb device.\n");
port = null;
} else {
port = driver.getPorts().get(0);
try {
port.open(connection);
//port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);
port.setParameters(9600, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);
mFinished = false;
start_read_thread();
} catch (IOException e) {
// Deal with error.
e.printStackTrace();
Log.d("GNSS", "M:IOException");
notificationLedBlink(LedTarget.LED3, LedColor.YELLOW, 1000);
//return;
} finally {
Log.d("GNSS", "M:finally");
}
}
}
//-----------------------------------------
super.onResume();
}
onPause()の処理
onPause()にはシリアル通信終了をはじめ、プラグイン終了の後片付け処理を記述しています。
@Override
protected void onPause() {
// Do end processing
//close();
//--------------- added code ---------------
Log.d("GNSS", "M:onPause()");
//インターバル撮影 可 の場合の後片付け
if (isIntervalMode) {
notificationLedHide(LedTarget.LED7);
if (isIntervalStat) {
isIntervalStat = false;
sendReq = true;
try {
//念のためのスレッド終了待ち
Thread.sleep(20);
} catch (InterruptedException e){
// Deal with error.
e.printStackTrace();
Log.d("GNSS", "T:InterruptedException");
notificationLedBlink(LedTarget.LED3, LedColor.YELLOW, 200);
}
}
}
//スレッドを終わらせる指示。終了待ちしていません。
mFinished = true;
//シリアル通信の後片付け ポート開けてない場合にはCloseしないこと
if (port != null) {
try {
port.close();
Log.d("GNSS", "M:onDestroy() port.close()");
notificationLedBlink(LedTarget.LED3, LedColor.BLUE, 200);
}catch(IOException e){
Log.d("GNSS", "M:onDestroy() IOException");
notificationLedBlink(LedTarget.LED3, LedColor.RED, 300);
}
} else {
Log.d("GNSS","M:port=null\n");
}
//-----------------------------------------
super.onPause();
}
追加したスレッドの処理(シリアル受信、測位ログ(TXT形式)保存、NMEA 0183 解釈、gpsInfoの編集と送信)
開発者しか使えない機能となりますが、プラグインを起動するたびにアプリケーション領域にファイルを作り、受け取った測位情報の生データを保存する処理も加えてあります。
このファイル数は「int maxFileNum = 20;」の部分で最大20ファイルまでが保存されているよう制限をかけてあります。必要に応じてカスタマイズしてください。
ファイルの拡張子を「.txt」としてありますが「.ubx」としておくと、後半で説明するu-blox社のツールから少し扱いやすくなります。
インターバル撮影開始をするところでは、以下コードで撮影間隔を4秒(THETA Vの最短撮影間隔、シャッター速度が4秒より長い場合はカメラ側が自動で最短の撮影間隔となるよう振る舞います)、撮影枚数を無制限(枚数0設定は無制限の意味となります)と設定しています。
Result = camera.setIntervalParam(4, 0);
以下、スレッドの処理の全体像です。
//読み取りスレッド
public void start_read_thread(){
new Thread(new Runnable(){
@Override
public void run() {
FileOutputStream out;
try {
notificationLedBlink(LedTarget.LED3, LedColor.MAGENTA, 500);
Log.d("GNSS", "Thread Start");
//Restriction on the number of log files
String[] files = fileList();
Arrays.sort(files);
int maxFileNum = 20;
if ( files.length >= maxFileNum) {
for( int i=0; i<= (files.length-maxFileNum); i++ ){
Log.d("GNSS", "delet file:" + files[i] );
deleteFile(files[i]);
}
}
//Create logfile
SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd_HHmmss");
final Date date = new Date(System.currentTimeMillis());
String FileName = "GNSS_Log_" + df.format(date) + ".txt";
out = openFileOutput(FileName,MODE_PRIVATE|MODE_APPEND);
//gpsInfo編集用バッファ
String lat = "";
String lng = "";
String _altitude = "";
String _dateTimeZone = "";
String utcYYYYMMDD = "";
String utcHHMMSS = "";
while(mFinished==false){
//シリアル通信 受信ポーリング部
byte buff[] = new byte[256];
int num = port.read(buff, buff.length);
if ( num > 0 ) {
String rcvStr = new String(buff, 0, num);
String[] splitSentence = rcvStr.split(",", 0);
Log.d("GNSS", rcvStr);
out.write(buff,0, rcvStr.length());
//[RMCセンテンス]
// 年月日はRMCセンテンスにしかない
// 信憑度、緯度、経度、時間はGGAセンテンスと重複
// 高度がない
// GGAセンテンスより先に受信している
if ( splitSentence[0].contentEquals("$GPRMC") && (splitSentence.length == 13) ) {
// gpsInfo : UTC 年月日 の編集
if ( splitSentence[9].length() == 6 ) {
utcYYYYMMDD = "20" + splitSentence[9].substring(4, 6) + ":" + splitSentence[9].substring(2, 4) + ":" + splitSentence[9].substring(0, 2);
//Log.d("GNSS", "YY:MM:DD(UTC)=" + utcYYMMDD);
} else {
utcYYYYMMDD = "";
}
}
//信憑性のある位置情報ならばsetGpsInfoを実行する
//[GGAセンテンス]
// UTC : 使う
// 緯度 : ddmm.mmmmmを分離 dd+(mm.mmmmm/60) とする
// N/S : 北緯はそのまま、南緯はマイナスを付与
// 経度 : dddmm.mmmmmを分離 ddd+(mm.mmmmm/60) とする Googleマップ形式
// E/W : 東経はそのまま、西経はマイナスを付与
// 位置特定品質 : 0は測位できていない、1or2なら測位できている
// 衛星使用数 : 未使用 3の場合高度の精度はでていない
// 水平精度低下率 : 未使用
// アンテナの海抜高さ : 使う
// 上記の単位 : M はメートル
// ジオイド高さ : 未使用
// 上記の単位 : M はメートル
// DGPS不使用のため基準地点は空欄
// *チェックサム : 今回はチェックしません
if ( splitSentence[0].contentEquals("$GPGGA") && (splitSentence.length == 15) ) {
//信憑度チェック
if ( splitSentence[6].contentEquals("1") || splitSentence[6].contentEquals("2") ) {
notificationLedBlink(LedTarget.LED3, LedColor.GREEN, 500);
// gpsInfo : lat の編集
Double latTop = Double.valueOf( splitSentence[2].substring(0,2) );
Double latEnd = Double.valueOf( splitSentence[2].substring(2,splitSentence[2].length()) );
Double latResult = latTop + (latEnd/60) ;
lat = String.format("%02.06f", latResult);
if ( splitSentence[3].contentEquals("S") ) {
lat = "-" + lat ;
}
// gpsInfo : lng の編集
Double lngTop = Double.valueOf( splitSentence[4].substring(0,3) );
Double lngEnd = Double.valueOf( splitSentence[4].substring(3,splitSentence[4].length()) );
Double lngResult = lngTop + (lngEnd/60) ;
lng = String.format("%02.06f", lngResult);
if ( splitSentence[5].contentEquals("W") ) {
lng = "-" + lng ;
}
// gpsInfo : _altitude の編集
_altitude = splitSentence[9]; //今回は値域チェックせずそのまま使う
// gpsInfo : UTC 時分秒 の取得後 _dateTimeZone の編集
if (splitSentence[1].length() == 9) {
utcHHMMSS = splitSentence[1].substring(0,2) + ":" + splitSentence[1].substring(2,4) + ":" + splitSentence[1].substring(4,6) ;
}
_dateTimeZone = utcYYYYMMDD + " " + utcHHMMSS + "+09:00"; // In this example, the time zone is fixed to JST.
//Log.d("GNSS", "UTC=" + _dateTimeZone);
// デバッグ表情報の編集
String logStr = "lat=" + lat +", lng=" + lng + ", height=" + _altitude + ", dateTime=" + _dateTimeZone + "\n";
Log.d("GNSS", "gpsInfo=" + logStr);
// gpsInfo 設定
HttpConnector camera = new HttpConnector("127.0.0.1:8080");
String setGpsInfoResult = camera.setGpsInfo(lat, lng, _altitude, _dateTimeZone);
Log.d("GNSS", "setGpsInfoResult:" + setGpsInfoResult);
} else {
notificationLedBlink(LedTarget.LED3, LedColor.MAGENTA, 500);
}
}
}
//ポーリングが高頻度になりすぎないよう10msスリープする
Thread.sleep(10);
//インターバル撮影 開始/停止指示 の実行部
if (sendReq == true){
String Result ;
HttpConnector camera = new HttpConnector("127.0.0.1:8080");
if ( isIntervalStat ) {
Result = camera.setIntervalParam(4, 0);
Log.d("GNSS", "T:camera.setIntervalParam()=" + Result);
Result = camera.startCapture("interval");
Log.d("GNSS", "T:camera.startCapture()=" + Result);
notificationLedShow(LedTarget.LED6);
} else {
Result = camera.stopCapture();
Log.d("GNSS", "T:camera.stopCapture()=" + Result);
notificationLedHide(LedTarget.LED6);
}
sendReq = false;
}
}
out.close();
} catch (IOException e) {
// Deal with error.
e.printStackTrace();
Log.d("GNSS", "T:IOException");
notificationLedBlink(LedTarget.LED3, LedColor.YELLOW, 100);
} catch (InterruptedException e) {
// Deal with error.
e.printStackTrace();
Log.d("GNSS", "T:InterruptedException");
notificationLedBlink(LedTarget.LED3, LedColor.YELLOW, 200);
} finally {
Log.d("GNSS", "T:finally");
}
}
}).start();
}
実際に使ってみた
wifi経由でVysorを使いプラグインにパーミッションを与える
「はじめに」の末尾にて「1点おおきな注意点(未解決事項)が・・・」とした事項が解決すると、この作業は不要となります。現時点では必要な作業ですので記しておきます。
作成したTHETAプラグインに接続したUSB機器へのパーミッションを与えるため、wifi経由でVysorが使える状態とした状態で、USB機器をTHETA Vに差し込んでください。
すると、Vysorに以下のようなダイアログが表示されますので「Use by default for this USB device」のチェックをONとして、ダイヤログのOKボタンを押してください。
上記操作をした以降はパーミッションを訪ねられることなくこのプラグインを使うことができます。
パーミッションは基本一度与えたらOKなのですが、完全電源Off(電源ボタンを長押ししてTHETA Vを終了)中の機器接続(or 機器を繋いだままの電源Off)からの電源onではパーミッションがないと認識されてしまいます。その場合は一度機器をUSBコネクタから抜き刺しすると正常に使えます。
なお、スリープ状態(電源ボタン短押ししてTHETA Vを終了)ではパーミッションは与えられたままとなります。そしてUSB機器への電源供給は継続されたままです。
少々ややこしい振る舞いですのでご注意ください。
プラグインの起動と終了
前述の通り、THETAプラグインのお作法から外れるのですが、OTGでのプラグイン起動をさせています。
OTGで起動した場合、LEDの点灯状態が狙いとおりではありません(プラグインから操っていないLEDが点灯したままとなっています)。しかし、プラグインが起動していることは認識できているので、一旦モードボタン長押しをしてプラグインを終了させた後、ふたたび正規手順でプラグインを起動させてください。
以降の説明は、正規手順でプラグインを動作させた場合の説明となります。
(どちらであっても、使用上さほどの問題はありませんが・・・)
測位状態(機器接続状態含む)の表示
本プラグインでは無線ランプの色で、測位状態(機器接続状態含む)を判るようにしました。
マゼンタが測位できていない状態、緑が測位できている状態です。通常はこのどちらかの表示になります。
GPS/GNSSレシーバーの接続状態やエラーなども表示するようにしています。
黄色はプラグインにパーミッションが与えられてない状態か接続不良、赤は機器を接続せずプラグインを起動した状態を示しています。
1枚撮影、インターバル撮影(開始/停止)の仕方と表示
本プラグインでは「動画記録ランプ」で1枚撮影とインターバル撮影のどちらが撮影可能かの識別を、「撮影モードランプのLive」でインターバル撮影が停止中か実行中かの識別ができるようにしました。
-
1枚撮影の仕方
「動画記録ランプ」が消灯した状態でシャッターボタンを押すと1枚撮影が行えます。
Modeボタンを短押しすると「インターバル撮影可能状態」へ移行できます。 -
インターバル撮影の仕方
「動画記録ランプ」が点灯した状態でシャッターボタンを押すと「撮影モードランプのLive」が点灯しインターバル撮影を開始します。インターバル撮影動作中にシャッターボタンを押すと「Live」が消灯してインターバル撮影を停止します。
インターバル撮影の状態に関わらず、Modeボタンを短押しすると「1枚撮影可能状態」へ移行できます。
1点、プラグインにおける便利な撮影トピックを紹介しておきます。
THETA V/SC/Sには「マイセッティング」と呼ばれる便利な機能があります。この機能を利用すると、THETA Vを電源Onしたときの撮影モードや露出補正値などの撮影諸設定を覚えておかせることができます。スマートフォンを繋がずに撮影するときに便利な機能です。
この機能は、本プラグインを使う時にも有効です(プラグインからのwebAPIを利用した撮影を行っているため)。
画像データの活用方法色々
1枚データの位置情報確認例
パソコン、スマートフォン問わず色々なアプリケーションが位置情報付き画像データに対応しています。
こちらのWebサイトで以下のような表示も可能です。
インターバル撮影で得た多量データの位置情報活用例
本プラグインを使ったインターバル撮影で夜のみなとみらい地区を練り歩いてみました。およそ35分間の撮影で540枚撮影できています。
このような多量データを活用できるアプリケーションも色々あり、今回は、Androidスマートフォンに全データを取り込んだあと、PhtoMapというアプリで軌跡を描かせてみました。
一番左側がインターバル撮影を開始した時点の姿です。USBコネクタにGPS/GNSSレシーバーを取り付けるため、本体と自撮り棒の間にはエクステンションアダプターTE-1というオプションを使っています。
中央は今回歩いた軌跡の全体像です。俯瞰の表示ですと写真が「まとめ表示」となりますが、一番右側のように拡大すると、写真の位置情報から描いた軌跡に一枚づつ写真が対応していることがわかると思います。
なお、夜の撮影でしたので前述のマイセッティングでISO感度優先を設定しておき、ブレをおさえつつ画質もある程度担保するようISO 800に固定して撮影していました。撮影した画像の例は以下です。暗い場所での気軽な360°撮影はTHETAが向いているのがわかりますね。
GPS/GNSSプラグインのインターバル撮影で練り歩きした撮影サンプルです - Spherical Image - RICOH THETA
Qiitaではtheta360.comの埋め込みタグが表示されないので、撮影画像をTHETA用編集アプリTHETA+でリトルプラネットという投影方式変換した画像も貼っておきます。
上のリンクをブラウザでみて頂くと同じ投影方式での表示もできますよ!
測位ログ
ソースコードの説明で「開発者しか使えない機能となりますが」と説明したおまけ機能、GPS/GNSSレシーバーから受け取ったNMEA 0183形式の生データをファイル保存した、測位ログの扱い方をまとめています。
ファイルの取り出し方
Android Studioの右下にある「Device File Explorer」をクリックするとアプリケーション領域のファイルもみることができます。
data -> data -> com.theta360.プラグイン名称 -> files フォルダ配下に GPS/GNSSレシーバーから受信した生データを保存しています。
こちらはファイルを右クリックすると出てくるメニュー「Save As ...」を選択して諸操作をおこなうとパソコンに取り出すことができます。
測位ログの活用(u-blox社 無償ツールの例)
上記のように取り出したファイルをu-blox社が無償で提供しているツール「u-center」にて表示した結果が以下です。いろいろな速度で再生したり地図の縮尺をかえたりすることもできます。刻々と変化する衛星の補足状態など多様な情報を視覚的に確認することができます。
他アプリからの測位ログ利用
少々余談となりますが、上記ログは、本プラグインを起動した状態のTHETA Vを以下のように車のダッシュボードにおいたまま「撮影せずに」取得したログです(撮影の有無にかかわらず保存できています)。
このようなログは、kml形式変換してGoogleマップに軌跡を描いたり、測位データがついていない画像に測位データをつけるソフトのインプットにしたりもできます。
接続機器へ電力供給をすることの影響
簡易的なツールでの測定ですが、衛星電波受信状態に関わらず、USBポートからGPS/GNSSレシーバーへ供給している電流は10mA程度でした(稀に表示が 0.02Aになることもありますが、基本0.01Aを表示しています)。
夜間の撮影実験では、35分間 540枚の撮影後、さらに、約40分間の測位ログ取得をしても電池残量がわずかにのこっていました。
常温屋内でランニング試験をしてみると 1時間55分の動作で 1609枚の撮影もできました。
THETA VからGPS/GNSSレシーバーへ給電しているといっても、使用時間や撮影可能枚数に大きな影響はなさそうです。
まとめ
Androidスマートフォンアプリ用として公開されているライブラリを利用することで、USB Host機能を利用したシリアル通信が行えるようになることがお分かりいただけたかと思います。
シリアル通信ができると、今回の事例のような既製品のみならず、電子工作の世界では有名な arduino をはじめとする廉価なマイコンボードとも連携できるようになります。THETAから手足が生えたかのように、さまざまなセンサーやモーターなどを拡張することも可能です。電子工作クラスタの食指が動き始めるのではないでしょうか?
ただし、Vysorを使わないとプラグインにパーミッションを与える手段がないという問題があります。ここを解決しないと折角作ったプラグインの恩恵を一般の方々がきがるに享受できなくなってしまうので、社内でなにか策がないか調査・検討をしてみます。
今回作成したGPS/GNSSプラグインに機能追加をするならばという視点では、以下のような事項が考えられます。
- webUIでインターバル撮影の諸設定(撮影間隔、撮影枚数)を変更可能とする
- webUIから測位ログをダウンロードできるようにする(iPhoneのブラウザでは厳しいかもしれません)
- webUIでGPS/GNSSレシーバーの諸設定可とする(シリアルのwrite応用にもなる)
- マイセッティングの機能が効くため必須ではないけれど webUIで撮影設定できても良いかもしれない
その他にも考えられるとはおもいますが、必要に応じてこのプラグインをベースにカスタマイズにトライして頂けると幸いです。
また、ソースコードの説明にその都度いいわけを記載しておりましたが・・・
コードの書き方やプログラム構造について、もう少し整えたほうが良いと思っています。