2014/03/01

USB赤外線リモコン アプリ

Nexus7からUSB赤外線リモコンを操る(前編)「(中編)」 「(後編)」 を元に簡単なリモコンアプリを作りました。

使い方

デバイスの準備

専用のデバイスが必要です。
Nexus7からUSB赤外線リモコンを操る(前編)」 を参照してください。

起動

アプリをインストールした状態でデバイスを接続するとダイアログが表示されるので「OK」をタップするとアプリが自動的に起動します。

赤外線の登録

アプリが起動したらメニューの「登録モード on/off」を選択して登録モードにします。

登録モードはボタンの枠が水色になります。
この状態で登録したいボタンをタップします。

ダイアログが出るのでデバイスに向けてリモコンを操作してください。

正常に登録できるとダイアログが消えるので続けて他のボタンを登録します。

赤外線の送信

もう一度、メニューの「登録モード on/off」を選択して登録モードを解除し各ボタンをタップすれば登録した赤外線が送信されます。

リモコンの選択

横方向にスワイプするとリモコンを選択できます。

登録データの保存と復元

メニューの「登録データ保存」/「登録データ復元」を選択すると登録データの保存と復元が行えます。

現在のところ保存先は Android/data/org.kotemaru.android.irrc/IrData.db に固定です。
ファイル形式は Sqlite です。

リモコン画面のカスタマイズ

現状ではリモコン画面のカスタマイズは Android の開発環境が無いとできません。
SVN からプロジェクトを落として Eclipse で開いてください。

リモコン画面は HTML で記述されています。 テンプレートとなる assets/remocon/1.TV.html を同じフォルダに別名をつけてコピーしてください。 自動的に新しいリモコンとして追加されます。

HTMLに id 属性の付いた <button> を配置すればそのまま登録可能なリモコンのボタンになります。 <button> の id 属性は DB 上のキーとなるので HTMLページ内で一意でなければなりません。

<title> タグはリモコン選択時にアプリタイトルとして表示されるので適当な物を指定して下さい。

アイコンについて

ボタンのアイコンは こちら からお借りしました。 300種類くらい有るので適当な物が見つかると思います。

雑感

本当は、リピート機能やリモコン画面の登録機能も付けたかったのですが需要が不明なのでここまでとしました。 このデバイスを使っている人は基本開発者だと思うで後は好きにしてください。(^^;

Android アプリとしては WebView の JavaScript から USBデバイスの制御をしているので中々面白いものになっていると思います。


2014/02/23

Nexus7からUSB赤外線リモコンを操る(後編)

Nexus7からUSB赤外線リモコンを操る(前編)「(中編)」の続きです。 先に前/中編をみてください。

赤外線リモコンキットのプロトコル

フォーラムにもなぜかプロトコルについての資料がありません。 ファームウェアのソース公開されているので自分で調べろ(or決めろ)って事でしょうか。

仕方ないのでこちらのサイトを参考にファームのソースからプロトコルを調べました。

基本形

パケットは64バイトの固定長です。

要求パケットの1バイト目にコマンドのコードが入り、 応答パケットの1バイト目に同じコマンドのコードが入って戻ってきます。

パケットの2バイト目以降が要求パラメータまたは応答の戻り値となっています。

応答にエラーコードと言うものは無いようなので Timeout で検出と思われます。

家電のリモコンから赤外線データの受信

リモコンから赤外線データを受信するにはデバイスを受信モードにしデータを取得してから受信モードを終了します。

操作パケットデータ(64byte固定)
受信モード開始
(1)  要求->0x53,0x01,0xff…0xff
(2)  応答<-0x53,0x00,0x00…0x00
データ取得(繰り返す)
(3)  要求->0x52,0xff,0xff…0xff
(4)  応答<-0x52,0xXX,0xXX…0xXX
受信モード終了
(5)  要求->0x53,0x00,0xff…0xff
(6)  応答<-0x53,0x00,0x00…0x00

リモコンからまだ赤外線データを受け取っていない場合は (4) の応答の2バイト目が 0x00 となる為、データが取得できるまで (3),(4) を繰り返します。

デバイスから赤外線データの送信

取得したデータを送信します。

(4) で受け取ったデータの1バイト目を送信コマンド(0x61)に差し替えて要求するだけです。
投げっぱなしで応答は有りません。

操作パケットデータ(64byte固定)
データ送信
(7)  要求->0x61,0xXX,0xXX…0xXX

プロトコルの実装

家電のリモコンから赤外線データの受信

応答が非同期となるのでリスナインターフェースを用意します。 後は、パケットを作って非同期タスクに投げるだけです。

public interface IrrcResponseListener {
    public void onIrrcResponse(byte[] data);
}

public void startReceiveIR(IrrcResponseListener listener) {
    byte[] buff = initBuffer(new byte[PACKET_SIZE], (byte) 0xff);
    buff[0] = RECEIVE_IR_MODE_CMD;
    buff[1] = 1;
    new RequestAsyncTask(listener).request(buff, true, false);
}

public void endReceiveIR(IrrcResponseListener listener) {
    byte[] buff = initBuffer(new byte[PACKET_SIZE], (byte) 0xff);
    buff[0] = RECEIVE_IR_MODE_CMD;
    buff[1] = 0;
    new RequestAsyncTask(listener).request(buff, true, false);
}

public void getReveiveIRData(IrrcResponseListener listener) {
    byte[] buff = initBuffer(new byte[PACKET_SIZE], (byte) 0xff);
    buff[0] = RECEIVE_IR_DATA_CMD;
    new RequestAsyncTask(listener).request(buff, true, true);
}

デバイスから赤外線データの送信

パケットを作って非同期タスクに投げるだけです。

public void sendData(byte[] buff) {
    buff[0] = SEND_IR_CMD;
    new RequestAsyncTask(null).request(buff, false, false);
}

非同期タスク

応答有無、リトライ有無の指定にしたがってプロトコルにそった送受信を行っているだけです。
異常系やキャンセル処理への考慮は不十分です。
#APIが混乱しているのは仕様ですw

private class RequestAsyncTask extends AsyncTask<byte[], Void, byte[]> {
    private IrrcResponseListener listener;
    private boolean withResponse = false;
    private boolean withRetry = false;

    public RequestAsyncTask(IrrcResponseListener listener) {
        this.listener = listener;
    }

    public void request(byte[] buff, boolean withResponse, boolean withRetry) {
        this.withResponse = withResponse;
        this.withRetry = withRetry;
        execute(buff);
    }

    @Override
    protected byte[] doInBackground(byte[]... args) {
        Log.d(TAG, "RequestAsyncTask start");
        try {
            byte[] reqData = args[0];
            byte[] resData = null;
            boolean isRetry = false;
            do {
                doRequest(reqData);
                if (withResponse) {
                    resData = doResponse();
                    if (resData[0] != reqData[0]) {
                        Log.e(TAG, "Bad resposne code " + resData[0]);
                        return null;
                    }
                    if (withRetry && resData[1] == 0x00) {
                        sleep(500);
                        isRetry = true;
                    } else {
                        isRetry = false;
                    }
                }
            } while (isRetry);
            return resData;
        } catch (Throwable t) {
            Log.e(TAG, t.getMessage(), t);
            return null;
        }
    }

    @Override
    protected void onPostExecute(byte[] result) {
        if (listener != null) {
            listener.onIrrcResponse(result);
        }
    }
    private void doRequest(byte[] buff) throws IOException {
        …省略(中編参照)
    }
    private byte[] doResponse() throws IOException {
        …省略(中編参照)
    }
}

動かしてみる

受信と送信のボタン2つだけの Activity を作って動かしてみました。

Nexus7に繋げるのですがここで一つ問題が。
赤外線リモコンキットのコネクタは Mini-USB なので micro-USB と直結しようとするとレアなケーブルが必要で手持ちに有りませんでした。

結果こんな事に(笑)

それはそれとして、
アプリの受信ボタンをタップしてからデバイスの受光部分に向けてリモコンを操作します。
電源ボタンを押してみました。

デバイスを家電機器に向けてアプリの送信ボタンをタップすると無事、家電の電源が入って実験成功です。

 

まとめ

これで Nexus7 から赤外線リモコンキットを操作することが可能になりました。

基本的に Android から USBデバイスを操作するのは同じ流れで行けると思うのですが やはり OSが一部デバイスをアプリに使わせてくれないのは致命的な問題のような気がします。 普通のUSBデバイスはファームの書き換えなんてさせてくれませんから。

追々、リモコンアプリを作って行きたいのですが ボタン配置のカスマイズをできるようにしないといけないので以外に難しそうです。

ソース全体は以下のSVNを参照して下さい。


2014/02/22

Nexus7からUSB赤外線リモコンを操る(中編)

Nexus7からUSB赤外線リモコンを操る(前編)」の続きです。先に前編をみてください。

以前 USB 接続をやった時は PC がホストで Android がデバイスだったけれど今回は Android がホストになるのでやること多いです。

基本的なやり方はいつもの勝手に翻訳さんのサイトを参照しました。

但し、割と内容が薄く書いて無い注意事項が結構あります。

マニフェスト

マニフェストはドキュメント通りです。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="org.kotemaru.android.irrc" android:versionCode="1" android:versionName="1.0" >
    <uses-sdk android:minSdkVersion="12" />
    <permission android:name="android.hardware.usb.host" ></permission>
    <uses-feature android:name="android.hardware.usb.host" android:required="true" />

    <application
        android:allowBackup="true" android:icon="@drawable/ic_launcher" android:label="@string/app_name" android:theme="@style/AppTheme" >
        <activity
            android:name="org.kotemaru.android.irrc.MainActivity"
            android:configChanges="orientation|screenSize"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <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" />
        </activity>
    </application>
</manifest>

USB_DEVICE_ATTACHED で Activity を起動する設定にすると 既に Activity が起動していても USB_DEVICE_ATTACHED で Activity が onCreate() から再起動されます。

LAUNCHER からも起動できるようにして有るとちょっとややこしい感じになります。

res/xml/device_filter.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <usb-device vendor-id="8938" product-id="30" />
</resources>

デバイスを特定する情報を記述します。 注意事項は値の記法が10進数なことです。 最初、16進数で書いてはまりました。

USBデバイスの構造

USBデバイスの構造は少し複雑なので整理します。
1つのUSBデバイスは複数のインターフェース(機能)を持ちます。通常は1つです。
各インターフェースは入出力の Endpoint を複数持ちます。通常は1つか2つです。

赤外線リモコンキットは4つのインタフェースを持ち以下のような構造になっていました。
但し、データ通信意外は前編のファームウェアによりダミーになっています。

  • UsbDevice : 赤外線リモコンキット
    • UsbInterface[0] : キーボード
      • UsbEndpoint[0] : IN(INTERRAPT)
      • UsbEndpoint[1] : OUT(INTERRAPT)
    • UsbInterface[1] : マウス
      • UsbEndpoint[0] : IN(INTERRAPT)
    • UsbInterface[2] : ボリュームコントローラ
      • UsbEndpoint[0] : IN(INTERRAPT)
    • UsbInterface[3] : データ通信
      • UsbEndpoint[0] : IN(INTERRAPT)
      • UsbEndpoint[1] : OUT(INTERRAPT)

UsbEndpoint のタイプは CONTROL,ISOC,BULK,INTERRAPT の4種類あります。 通常アプリが使用するのは BLUK か INTERRAPT で赤外線リモコンキットは IN/OUT ともに INTERRAPT(非同期) で通信します。

詳細は以下のサイトが詳しいです。

デバイスの認識

起動直後の処理

public static IrrcUsbDriver init(MainActivity activity, String permissionName) {
    IrrcUsbDriver driver = new IrrcUsbDriver(activity, permissionName);
    // USB_DEVICE_ATTACHEDから起動された場合は intent がデバイスを持っている。
    UsbDevice device = activity.getIntent().getParcelableExtra(UsbManager.EXTRA_DEVICE);
    if (device == null) {
        // LAUNCHER からの起動の場合は接続済デバイス一覧から検索する。
        device = findDevice(driver.usbManager, VENDER_ID, PRODUCT_ID);
    }
    /*
     * USB_DEVICE_ATTACHED で起動するように AndroidManifest.xml を記述すると
     * USB_DEVICE_ATTACHED で必ず onCreate() が呼ばれるので Activity から設定した Receiver は呼ばれない。
     * 従って、ここで onAttach() を呼ぶ。
     */
    driver.onAttach(device);
    return driver;
}

USB_DEVICE_ATTACHED から Activity が起動された場合は intent がデバイスを持っているでそのままデバイス認識の処理に入ります。

LAUNCHER から起動された場合は自前でデバイス一覧から検索します。 それでも見つからない場合の処理はアプリしだいです。

デバイス認識の処理

public String onAttach(UsbDevice device) {
    Log.d(TAG, "onAttach:" + device);
    usbDevice = device;
    if (usbDevice == null) {
        Log.e(TAG, "Not found USB Device.");
        return "Not found USB Device.";
    }
    if (usbManager.hasPermission(usbDevice)) {
        return onStart(usbDevice);
    } else {
        // デバイスの利用許可をユーザに求める。
        // 結果は UsbReceiver.onReceive()にコールバック。
        usbManager.requestPermission(usbDevice, permissionIntent);
    }
    return null;
}

アプリがUSBデバイスを使用するにはユーザの許可が要ります。 許可が無い場合はユーザに許可を求めるリクエストを投げて Receiver で受け取ります。

許可があればデバイスとの接続を開始します。

デバイス接続の処理

public String onStart(UsbDevice device) {
    Log.d(TAG, "onStart:" + device);
    if (! device.equals(usbDevice)) {
        return "No device attach.";
    }
    if (! usbManager.hasPermission(usbDevice)) {
        return "No device permission.";
    }

    usbConnection = usbManager.openDevice(usbDevice);
    // TODO:インターフェースの検出は端折ってます。
    UsbInterface usbIf = usbDevice.getInterface(INTERFACE_INDEX);

    // EndPointの検索。分かってる場合は直接取り出しても良い。
    for (int i = 0; i < usbIf.getEndpointCount(); i++) {
        UsbEndpoint ep = usbIf.getEndpoint(i);
        Log.d(TAG, "tye=" + ep.getType());
        if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_INT) {
            if (ep.getDirection() == UsbConstants.USB_DIR_IN) {
                endpointIn = ep;
            } else if (ep.getDirection() == UsbConstants.USB_DIR_OUT) {
                endpointOut = ep;
            }
        }
    }
    if (endpointIn == null || endpointOut == null) {
        Log.e(TAG, "Device has not IN/OUT Endpoint.");
        return "Device has not IN/OUT Endpoint.";
    }
    // デバイスの確保
    usbConnection.claimInterface(usbIf, true);
    isReady = true;
    return null;
}

この辺りはドキュメント通りです。
Endpoint のタイプは分かっていますがあえてチェックしています。

デバイスの終了処理

public String onDetach(UsbDevice device) {
    Log.d(TAG, "onDetach:" + device);

    if (!device.equals(usbDevice)) {
        Log.d(TAG, "onDetach: Other device.");
        return "Other device";
    }

    if (usbConnection != null) {
        UsbInterface usbIf = usbDevice.getInterface(INTERFACE_INDEX);
        usbConnection.releaseInterface(usbIf);
        usbConnection.close();
    }
    usbConnection = null;
    usbDevice = null;
    isReady = false;
    return null;
}

ここもドキュメント通りです。注意事項はありません。

レシーバ

レシーバの登録

public static UsbReceiver init(MainActivity activity, Driver driver, String permissionName) {
    UsbReceiver receiver = new UsbReceiver(activity, driver, permissionName);
    IntentFilter filter = new IntentFilter();
    filter.addAction(permissionName);  // USBデバイスの利用許可の通知を受ける。
    filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
    activity.registerReceiver(receiver, filter);
    return receiver;
}

デバイスの利用許可とDETACHEDを受け取ります。

レシーバの処理

@Override
public void onReceive(Context context, Intent intent) {
    String action = intent.getAction();
    Log.d(TAG,"onReceive:"+action);
    UsbDevice device = (UsbDevice)intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
    if (permissionName.equals(action)) {
        String errorMeg = driver.onStart(device);
        if (errorMeg != null) {
            activity.errorDialog(errorMeg);
        }
    } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) {
        if (driver.onDetach(device) == null) {
            activity.finish();
        }
    }
}

デバイス使用許可が来たらデバイスの接続開始処理を呼びます。
DETACHED で Activity を終わらせていますがアプリの仕様しだいです。

データの送受信

USB_ENDPOINT_XFER_INT(非同期) の通信は UI スレッドでは行えません。 必ず、AsyncTask か Thread で行います。

非同期パケット送信

    private void doRequest(byte[] buff) throws IOException {
        Log.d(TAG, "request:" + dump(buff));

        ByteBuffer buffer = ByteBuffer.allocate(buff.length);
        UsbRequest request = new UsbRequest();
        buffer.put(buff);

        request.initialize(usbConnection, endpointOut);
        request.queue(buffer, buff.length);

        UsbRequest finishReq;
        while ((finishReq = usbConnection.requestWait()) != request) {
            if (finishReq == null) throw new IOException("Request failed.");
            sleep(100);
        }
    }

非同期パケット送信の方法はこれでほぼ定形だと思います。

requestWait() の戻り値は同時に走っている他の UsbRequest の場合もあるのでループでチェックします。 null は何らかのエラーが有ったとき返るようです。

非同期パケット受信

    private byte[] doResponse() throws IOException {
        ByteBuffer buffer = ByteBuffer.allocate(endpointIn.getMaxPacketSize());
        buffer.clear();
        UsbRequest request = new UsbRequest();
        request.initialize(usbConnection, endpointIn);
        request.queue(buffer, endpointIn.getMaxPacketSize());

        UsbRequest finishReq;
        while ((finishReq = usbConnection.requestWait()) != request) {
            if (finishReq == null) throw new IOException("Request failed.");
            sleep(100);
        }

        // Note: OSバージョンにより flip() の必要性が異なる気がする...
        if (buffer.remaining() == 0) buffer.flip();

        byte[] buff = new byte[buffer.remaining()];
        buffer.get(buff);
        Log.d(TAG, "response:" + dump(buff));
        return buff;
    }

非同期パケット受信もこれでほぼ定形だと思います。

謎なのは エミュレータ(4.0.4) では buffer.flip() は必要無かったのですが Nexus7(4.4.2) では必要になりました。 赤外線リモコンキットはパケットが固定長なのでこのコードで どちらも動作しますがデバイスによっては OSバージョンのチェックが必要かもしれません。

感想

通信できるようになるまで作法と言うか手順が多いですね。
通信自体も非同期だと一手間かかります。
Android の USBホスト実装は基本的にこの形に乗りそうです。

ソース全体は以下のSVNを参照して下さい。

いよいよリモコンを操ります。
Nexus7からUSB赤外線リモコンを操る(後編)」に続きます。


2014/02/21

Nexus7からUSB赤外線リモコンを操る(前編)

スマホを汎用の赤外線リモコンにしたいと言う需要は結構有そうに思うのだがどうだろう。
少なくとも私は以前から欲しかった。

で密林さんで見つけたのがこれ

FRISKのケースに入ると言うUSB接続のマルチリモコンのキットだ。
安いのでキットの方をポチったら翌々日に到着。

部品数が少ないのでハンダごて使える人なら簡単に作れます。

完成品:

PCに メーカのサイト から落としてきたアプリを入れて動作確認するとサクッと動きました。

Android との接続

いきなりはまります。
接続してアプリから UsbManager.getDeviceList() としても何も取得できません。 調べると OS レベルでは認識しているようなのですがアプリから見えないようです。

「UsbManager.getDeviceList empty」でググると一杯出てくるのですがどうも マウス、キーボード、USBメモリはアプリには見せないようになっているらしいです。
仕様として明確になっている記述は見つかりません。
機種によって動く動かないの記事がみられるので USBホスト機能自体ベンダー依存なのかもしれません。

この赤外線リモコンキットはキーボードとマウスのインターフェースを持っているため OSに誤認されデバイス全体が隠蔽されてしまっているようです。

ファームウェアの書き換え

このキットはファームウェアのソースが公開されていてフォーラムからDLできます。 そこでファームウェアを書き換えてキーボード、マウスの機能を無効化する事にします。

フォーラムからファームウェア書換えツールをDLして修正したファームウェアに書き換えます。

ファームウェア書き換え手順

  1. デバイスを外して 赤いスイッチを BOOT側 にします。
  2. デバイスを接続して書換えツール(HIDBootLoader.exe) を起動します。
  3. 「Open hex file」ボタンを押してAndroid用修正ファームウェア(RemoconServant-for-android.hex)を選択します。
  4. 「Program/Verify」ボタンを押して Complete と表示されれば書き換え終了です。
  5. デバイスを外して 赤いスイッチを元に戻します。

ファームウェア修正内容

USBのdescriptor定義をいじってキーボード、マウスと認識されないようにしただけです。

自分でビルドしたい人は以下の修正パッチを当てて -D__FOR_ANDROID でコンパイルして下さい。

ビルドツールが見つけ辛いのでリンクを張って置きます。

ファームウェア書き換え結果

ファームウェア書き換え後、Android に接続するとちゃんとアプリから認識できました。

リモコン アプリ

感想

さらっと書いてますが、ここまでめっちゃ大変でした。

アプリから認識されないUSBデバイスの種類が曖昧だし、 ファームウェアのコンパイラはチップメーカのサイトから消えてるし。

ともあれやっとこれで Android のプログラムに入れます。

Nexus7からUSB赤外線リモコンを操る(中編)」に続きます。


2013/10/20

Nexus7でPASMO(Suica)の履歴を読んでみた

先日、財布の上にNexus7を置いたら「ポロリ〜ン」って変な音がしたので何かと思ったら PASMO に反応していた。

Androidは2.3からFelica等の非接触カードに対応しているらしくNexus7にも付いていたわけだ。
音を鳴らしたアプリは分からずじまいだが面白そうなので自分でアプリを書いてみた。

PASMO/Suicaの仕様

  • Felica仕様の詳細は Sonyの「Felicaカードユーザマニュアル抜粋版」と言うPDFをDLして読んでください。
    • Felica技術情報のページ
    • APIで使うのはこの中の「2.3 アプリケーション層」のコマンドパケットです。
    • Pollingまでは終わった状態からAndroidアプリは始まります。
    • 普通使うのは Read/Write Without Encryption くらいですが引数の指定の仕方が結構ややこしいです。
    • ざっくりとした解説はこちらのページにあります。
  • Felicaの用語
    • サービスコード:
      • 1枚のカードに複数のサービスを同居させる為にサービスに割り当てられた2byteのコード。
      • コードの管理者は良く分かりません。Sony?
    • ブロック:
      • サービスの扱うデータブロックの事で16byte固定です。
      • 何ブロック使えるかはサービス毎の割り当てにより決まるようです。
      • ブロックの中の仕様はサービス毎に仕様が定義されます。
  • PASMO/Suica の仕様は非公開ですが有志で解析された情報があるのでそれを参考にします。
    • felicalibのWiki
    • ここから履歴のサービスコードが 0x090f とわかりました。
    • 履歴の保存フォーマットもここを参考にしています。

AndroidManifext.xml

  • NFC の uses-feature と uses-permission を宣言します。
  • intent-filter でカードを認識したら Activity を起動するようにします。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="org.kotemaru.android.sample.nfc"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="14" android:targetSdkVersion="17" />

    <uses-feature android:name="android.hardware.nfc" android:required="true" />    ←追加
    <uses-permission android:name="android.permission.NFC" />                       ←追加

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name="org.kotemaru.android.sample.nfc.MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.nfc.action.TAG_DISCOVERED" />         ←変更
                <category android:name="android.intent.category.DEFAULT" />         ←変更
            </intent-filter>

            <meta-data      ←追加
                android:name="android.nfc.action.TAG_DISCOVERED"
                android:resource="@xml/nfc_filter" />
        </activity>
    </application>
</manifest>

res/xml/nfc_filter.xml

  • 使いたいカードのパッケージを設定します。
  • 今回はFelicaだけなので NfcF です。

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2" >
  <tech-list>
    <tech>android.nfc.tech.NfcF</tech>
  </tech-list>
</resources>

MainActivity.java

  • IntentからカードIDを取得してbyte配列のFelicaコマンドを送信します。
  • byte配列のレスポンスが帰るので解析すれば終わりです。
  • 履歴の保存レコード数は不明ですが私のPASMOでは最新15件しかありませんでした。
    • 1回の要求で取得できるレコード数は「製品により異なります」とマニュアルに書いてありました。
  • 注意事項:byte配列はリトル/ビッグ エンディアンが混在しています。仕様を良く確認してください。
package org.kotemaru.android.sample.nfc;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import android.nfc.NfcAdapter;
import android.nfc.Tag;
import android.nfc.tech.NfcF;
import android.os.Bundle;
import android.app.Activity;
import android.content.Intent;
import android.util.Log;
import android.widget.TextView;

public class MainActivity extends Activity {
    private static final String TAG = "NFCSample";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        TextView textView1 = (TextView) this.findViewById(R.id.textView1);

        // カードID取得。Activityはカード認識時起動に設定しているのでここで取れる。
        byte[] felicaIDm = new byte[]{0};
        Intent intent = getIntent();
        Tag tag = (Tag) intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
        if (tag != null) {
            felicaIDm = tag.getId();
        }


        NfcF nfc = NfcF.get(tag);
        try {
            nfc.connect();
            byte[] req = readWithoutEncryption(felicaIDm, 10);
            Log.d(TAG, "req:"+toHex(req));
            // カードにリクエスト送信
            byte[] res = nfc.transceive(req);
            Log.d(TAG, "res:"+toHex(res));
            nfc.close();
            // 結果を文字列に変換して表示
            textView1.setText(parse(res));
        } catch (Exception e) {
            Log.e(TAG, e.getMessage() , e);
            textView1.setText(e.toString());
        }
    }

    /**
     * 履歴読み込みFelicaコマンドの取得。
     * - Sonyの「Felicaユーザマニュアル抜粋」の仕様から。
     * - サービスコードは http://sourceforge.jp/projects/felicalib/wiki/suica の情報から
     * - 取得できる履歴数の上限は「製品により異なります」。
     * @param idm カードのID
     * @param size 取得する履歴の数
     * @return Felicaコマンド
     * @throws IOException
     */
    private byte[] readWithoutEncryption(byte[] idm, int size)
            throws IOException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream(100);

        bout.write(0);           // データ長バイトのダミー
        bout.write(0x06);        // Felicaコマンド「Read Without Encryption」
        bout.write(idm);         // カードID 8byte
        bout.write(1);           // サービスコードリストの長さ(以下2バイトがこの数分繰り返す)
        bout.write(0x0f);        // 履歴のサービスコード下位バイト
        bout.write(0x09);        // 履歴のサービスコード上位バイト
        bout.write(size);        // ブロック数
        for (int i = 0; i < size; i++) {
            bout.write(0x80);    // ブロックエレメント上位バイト 「Felicaユーザマニュアル抜粋」の4.3項参照
            bout.write(i);       // ブロック番号
        }

        byte[] msg = bout.toByteArray();
        msg[0] = (byte) msg.length; // 先頭1バイトはデータ長
        return msg;
    }

    /**
     * 履歴Felica応答の解析。
     * @param res Felica応答
     * @return 文字列表現
     * @throws Exception
     */
    private String parse(byte[] res) throws Exception {
        // res[0] = データ長
        // res[1] = 0x07
        // res[2〜9] = カードID
        // res[10,11] = エラーコード。0=正常。 
        if (res[10] != 0x00) throw new RuntimeException("Felica error.");

        // res[12] = 応答ブロック数
        // res[13+n*16] = 履歴データ。16byte/ブロックの繰り返し。
        int size = res[12];
        String str = "";
        for (int i = 0; i < size; i++) {
            // 個々の履歴の解析。
            Rireki rireki = Rireki.parse(res, 13 + i * 16);
            str += rireki.toString() +"\n";
        }
        return str;
    }

    private String toHex(byte[] id) {
        StringBuilder sbuf = new StringBuilder();
        for (int i = 0; i < id.length; i++) {
            String hex = "0" + Integer.toString((int) id[i] & 0x0ff, 16);
            if (hex.length() > 2)
                hex = hex.substring(1, 3);
            sbuf.append(" " + i + ":" + hex);
        }
        return sbuf.toString();
    }

}

Rireki.java

  • 履歴のパーザとBeanです。
  • 駅コードから駅名を引くのは大きなテーブルが必要なので今回はパスです。
package org.kotemaru.android.sample.nfc;

import android.util.SparseArray;

/**
 * Pasumo履歴レコード。
 * - 資料:http://sourceforge.jp/projects/felicalib/wiki/suica
 */
public class Rireki {
    public int termId;
    public int procId;
    public int year;
    public int month;
    public int day;
    public String kind;
    public int remain;
    public int seqNo;
    public int reasion;

    public Rireki(){
    }

    public static Rireki parse(byte[] res, int off) {
        Rireki self = new Rireki();
        self.init(res, off);
        return self;
    }

    private void init(byte[] res, int off) {
        this.termId = res[off+0]; //0: 端末種
        this.procId = res[off+1]; //1: 処理
        //2-3: ??
        int mixInt = toInt(res, off, 4,5);
        this.year  = (mixInt >> 9) & 0x07f;
        this.month = (mixInt >> 5) & 0x00f;
        this.day   = mixInt & 0x01f;

        if (isBuppan(this.procId)) {
            this.kind = "物販";
        } else if (isBus(this.procId)) {
            this.kind = "バス";
        } else {
            this.kind = res[off+6] < 0x80 ? "JR" : "公営/私鉄" ;
        }
        this.remain  = toInt(res, off, 11,10); //10-11: 残高 (little endian)
        this.seqNo   = toInt(res, off, 12,13,14); //12-14: 連番
        this.reasion = res[off+15]; //15: リージョン 
    }

    private int toInt(byte[] res, int off, int... idx) {
        int num = 0;
        for (int i=0; i<idx.length; i++) {
            num = num << 8;
            num += ((int)res[off+idx[i]]) & 0x0ff;
        }
        return num;
    }
    private boolean isBuppan(int procId) {
        return procId == 70 || procId == 73 || procId == 74 
                || procId == 75 || procId == 198 || procId == 203;
    }
    private boolean isBus(int procId) {
        return procId == 13|| procId == 15|| procId ==  31|| procId == 35;
    }

    public String toString() {
        String str = seqNo
                +","+TERM_MAP.get(termId)
                +","+ PROC_MAP.get(procId)
                +","+kind
                +","+year+"/"+month+"/"+day
                +",残:"+remain+"円";
        return str;
    }

    public static final SparseArray<String> TERM_MAP = new SparseArray<String>();
    public static final SparseArray<String> PROC_MAP = new SparseArray<String>();
    static {
        TERM_MAP.put(3 , "精算機");
        TERM_MAP.put(4 , "携帯型端末");
        TERM_MAP.put(5 , "車載端末");
        TERM_MAP.put(7 , "券売機");
        TERM_MAP.put(8 , "券売機");
        TERM_MAP.put(9 , "入金機");
        TERM_MAP.put(18 , "券売機");
        TERM_MAP.put(20 , "券売機等");
        TERM_MAP.put(21 , "券売機等");
        TERM_MAP.put(22 , "改札機");
        TERM_MAP.put(23 , "簡易改札機");
        TERM_MAP.put(24 , "窓口端末");
        TERM_MAP.put(25 , "窓口端末");
        TERM_MAP.put(26 , "改札端末");
        TERM_MAP.put(27 , "携帯電話");
        TERM_MAP.put(28 , "乗継精算機");
        TERM_MAP.put(29 , "連絡改札機");
        TERM_MAP.put(31 , "簡易入金機");
        TERM_MAP.put(70 , "VIEW ALTTE");
        TERM_MAP.put(72 , "VIEW ALTTE");
        TERM_MAP.put(199 , "物販端末");
        TERM_MAP.put(200 , "自販機");

        PROC_MAP.put(1 , "運賃支払(改札出場)");
        PROC_MAP.put(2 , "チャージ");
        PROC_MAP.put(3 , "券購(磁気券購入)");
        PROC_MAP.put(4 , "精算");
        PROC_MAP.put(5 , "精算 (入場精算)");
        PROC_MAP.put(6 , "窓出 (改札窓口処理)");
        PROC_MAP.put(7 , "新規 (新規発行)");
        PROC_MAP.put(8 , "控除 (窓口控除)");
        PROC_MAP.put(13 , "バス (PiTaPa系)");
        PROC_MAP.put(15 , "バス (IruCa系)");
        PROC_MAP.put(17 , "再発 (再発行処理)");
        PROC_MAP.put(19 , "支払 (新幹線利用)");
        PROC_MAP.put(20 , "入A (入場時オートチャージ)");
        PROC_MAP.put(21 , "出A (出場時オートチャージ)");
        PROC_MAP.put(31 , "入金 (バスチャージ)");
        PROC_MAP.put(35 , "券購 (バス路面電車企画券購入)");
        PROC_MAP.put(70 , "物販");
        PROC_MAP.put(72 , "特典 (特典チャージ)");
        PROC_MAP.put(73 , "入金 (レジ入金)");
        PROC_MAP.put(74 , "物販取消");
        PROC_MAP.put(75 , "入物 (入場物販)");
        PROC_MAP.put(198 , "物現 (現金併用物販)");
        PROC_MAP.put(203 , "入物 (入場現金併用物販)");
        PROC_MAP.put(132 , "精算 (他社精算)");
        PROC_MAP.put(133 , "精算 (他社入場精算)");
    }
}

実行結果

  • 実行前に「無線とネットワーク」の設定で NFC を有効にする必要があります。
  • とりあえず過去10件分の履歴の表示に成功しました。

雑感

データの解析が面倒でしたが思ったより感単にアクセスできました。
白カードを購入すれば書き込みも出来るようなので簡易のカード鍵とかは簡単に作れそうです。

このサンプルのSVN:


2013/10/10

WindowsUpdateしたらNexus7認識しなくなった

今日の Windows update の直後から Windows7 が Nexus7 を認識しなくなった。
ドライバは認識しているのだがエクスプローラとか Eclipse とかのアプリが反応しない。

俺の環境だけ?

とりあえず Linux で開発はできるから困らないけど、修正 update 来ないと面倒くさいなぁ。

P.S:対処方判明しました。

  • 設定から「ストレージ」で右上のメニューを開いて「USBでパソコンに接続」で「カメラ(PTP)」を選択

これで再接続すると認識されました。 元々Win8で発生していた問題のようですがupdateでWin7にもバグが更新されてしまったようです。

ファイル転送が画像しかできなくなりますが仕方ないですね。


2013/08/11

Nexus7のカメラでQRコードを読込んでみた

Androidのカメラアプリを作ってみようと思ったのだが Nexus7 にはフロントカメラしか無い。orz
QRコード・リーダーぐらいならなんとかなるかと思い手を付けてみた。

カメラの準備

普通は以下のようにするとカメラが準備できるのだが Nexus7 だとリアカメラを開こうとして失敗する。

Camera camera = Camera.open();

Nexus7 のフロントカメラを開くにはこうする。

int cameraId = 0;
Camera camera = Camera.open(cameraId);

cameraId はハードのカメラの実装状況によりまちまちなので正しくはこんなことする必要が有りそう。

Camera.CameraInfo info = new Camera.CameraInfo();
for (int id=0; id<Camera.getNumberOfCameras(); id++) {
    Camera.getCameraInfo(id, info);
    if (info.??? == ???) { // 使いたいカメラの条件式
        cameraId = id;
    }
}

AnsroidManufest.xmlにカメラのパーミッションも必要。

<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="true"/>
<!--
<uses-feature android:name="android.hardware.camera.front" android:required="true"/>
-->

android.hardware.camera.front は無くても良いらしい...

プレビュー

SurfaceView を使う。 ググれば解説多数なので割愛。

QRコードのライブラリ

QRコードのライブラリは ZXing を使う。 google が開発しオープン化されている物。

ここから落せる。

しかし、現行の ZXing-2.2.zip にはソースしか入っていないので以下から2.3-SNAPのjarを落してきた。

  • https://oss.sonatype.org/content/repositories/snapshots/com/google/zxing/

必要なものは core-xxxxx.jar と javase-xxxxxx.jar のみ。
eclipse の Android プロジェクトの libs に入れておけば利用可能。

使い方はサンプル・コードの onPreviewFrame() 内で完結している。

注意するのはフロントカメラは左右反転しているので PlanarYUVLuminanceSource コンストラクタの最後の引数が ture になっている必要がある。

サンプル・コード

このソースだけ。リソースも参照していない。

package org.kotemaru.sample.camera;

import java.io.IOException;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.PlanarYUVLuminanceSource;
import com.google.zxing.Reader;
import com.google.zxing.Result;
import com.google.zxing.common.HybridBinarizer;

import android.app.Activity;
import android.content.res.Configuration;
import android.hardware.Camera;
import android.hardware.Camera.AutoFocusCallback;
import android.os.Bundle;
import android.util.Log;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.ViewGroup;
import android.widget.Toast;

public class MainActivity extends Activity {
    private static final String TAG = "CameraSample";

    private int CAMERA_ID = 0; // for Nexus7

    private Camera camera;
    private SurfaceView surfaceView;
    private CameraListener cameraListener = new CameraListener();

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        surfaceView = new SurfaceView(this);
        setContentView(surfaceView);
    }

    @Override
    protected void onPostCreate(Bundle savedInstanceState) {
        super.onPostCreate(savedInstanceState);
        SurfaceHolder holder = surfaceView.getHolder();
        holder.addCallback(cameraListener);
    }

    private int getOrientation() {
        return getResources().getConfiguration().orientation;
    }

    private class CameraListener implements
            SurfaceHolder.Callback,
            AutoFocusCallback,
            Camera.PictureCallback,
            Camera.PreviewCallback
    {

        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            camera = Camera.open(CAMERA_ID);
            try {
                camera.setPreviewDisplay(holder);
            } catch (IOException e) {
                Log.e(TAG, e.toString(), e);
            }
        }

        @Override
        public void surfaceChanged(SurfaceHolder holder, int format,
                int width, int height) {

            // カメラのプレビューサイズをViewに設定
            Camera.Parameters parameters = camera.getParameters();
            Camera.Size size = parameters.getSupportedPreviewSizes().get(0); // 0=最大サイズ 
            parameters.setPreviewSize(size.width, size.height);
            camera.setParameters(parameters);

            // 画面回転補正。
            ViewGroup.LayoutParams layoutParams = surfaceView.getLayoutParams();
            if (getOrientation() == Configuration.ORIENTATION_PORTRAIT) {
                camera.setDisplayOrientation(90);
                layoutParams.width = size.height;
                layoutParams.height = size.width;
            } else {
                camera.setDisplayOrientation(0);
                layoutParams.width = size.width;
                layoutParams.height = size.height;
            }
            surfaceView.setLayoutParams(layoutParams);

            // オートフォーカス設定。
            camera.autoFocus(cameraListener);

            camera.startPreview();
        }

        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            camera.autoFocus(null);
            camera.setPreviewCallback(null);
            camera.release();
            camera = null;
        }

        @Override
        public void onAutoFocus(boolean success, Camera camera) {
            if (success) {
                Log.d(TAG, "focus");
                // プレビューのデータ取得。
                camera.setPreviewCallback(cameraListener);
                // フルサイズ画像はTODO
                //camera.takePicture(null,null,cameraListener);
            }
        }

        @Override
        public void onPictureTaken(byte[] data, Camera camera) {
            // フルサイズ画像もやることは同じ。
            onPreviewFrame(data, camera);
        }

        @Override
        public void onPreviewFrame(byte[] data, Camera camera) {
            // 処理は1回なのでコールバック取り消し
            camera.setPreviewCallback(null);

            // 基礎データ取得
            Camera.CameraInfo info = new Camera.CameraInfo();
            Camera.getCameraInfo(CAMERA_ID, info);
            int w = camera.getParameters().getPreviewSize().width;
            int h = camera.getParameters().getPreviewSize().height;
            //int w = camera.getParameters().getPictureSize().width;
            //int h = camera.getParameters().getPictureSize().height;
            boolean isMirror = (info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT);

            // プレビュー画像の型変換
            PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource(
                    data, w, h, 0, 0, w, h, isMirror);
            BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));

            // QRコード読み込み。
            Reader reader = new MultiFormatReader();
            try {
                Log.d(TAG, "decode");
                Result result = reader.decode(bitmap);
                String text = result.getText();

                Toast.makeText(MainActivity.this, text, Toast.LENGTH_LONG).show();
                Log.i(TAG, "result:" + text);
                camera.stopPreview();
                camera.autoFocus(null);
            } catch (Exception e) {
                // QRコード認識失敗でも例外発生する。
                Log.d(TAG, "decode-fail:" + e.toString());
                camera.autoFocus(cameraListener);
            }
        }
    };
}

実行結果

さすがにエミュレータでは動かないので実機で確認。

一応、QRコードを認識する事には成功した。 但し、QRコード自体をかなり拡大しないと認識できない。 小さいとカメラを非常に近づける必要があるためピントが合わないのかもしれない。

それ以前にプレビューを見るのに横からのぞき込まないといけないと言う致命的欠陥が...
(最初から分かっていた訳だがw)

技術的に可能だけど使い物にならないと言う予想通りの結論に達したのでこれで終りにしよう。



参考にさせて頂いたサイト:


2013/06/29

Nexus7を外部モニタ化 (2)

Nexus7を外部モニタ化した時に邪魔だったVNCクライアントのナビゲーションバー消せないか調べてみた。

結論から言うとAndroid/4.1で追加されたAPIを使えばアプリ単位で消せる。
OS全体で消そうとするとroot権が必要になる。

前回試したアプリの中で androidVNC がソース公開されていたのでこれに 自前のパッチを当てて試したところ 800x1280 の完全な全画面モードでVNCを使う事ができた。

終了。

じゃあ、野良ビルドでも公開しようかなと思ったらここで問題が。
apkを作ってアプリを実機にインストールしようても 「×アプリはインストールされません。」のメッセージが出る。

eclipseからデバッグモードでのインストールは問題無い。

うーん、一度同じアプリをGooglePlayからインストールしているから 正規の証明書が残ってるって事だろうか?
これ以上の情報が無く対処しようがないので野良ビルドの公開は諦めとします。

=> 出来ました Nexus7を外部モニタ化 (3)

...

せっかくなのでビルド手順はメモして置きます。

androidVNCのSVNからプロジェクトを2つチェックアウトします。

  • http://android-vnc-viewer.googlecode.com/svn/trunk/eclipse_projects/
    • androidVNC
    • ZoomerWithKeys

trunkの日付が 2011年になっているのでこのプロジェクトは活動停止中っぽいです。

ビルドには sqlitegen というプラグインが必要です。 (専用プラグインのような気がします。)
以下のページの案内にしたがってインストールしてください。

以下のパッチを当てます。

Index: AndroidManifest.xml
===================================================================
--- AndroidManifest.xml (revision 204)
+++ AndroidManifest.xml (working copy)
@@ -1,14 +1,14 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    package="android.androidVNC" android:installLocation="auto" android:versionCode="13" android:versionName="0.5.0">
+    package="android.androidVNC" android:installLocation="auto" android:versionCode="14" android:versionName="0.5.0">
     <application android:icon="@drawable/icon" android:label="androidVNC" android:debuggable="false">
-       <activity android:label="@string/app_name" android:name="androidVNC" android:screenOrientation="landscape">
+       <activity android:label="@string/app_name" android:name="androidVNC" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN"></action>
                <category android:name="android.intent.category.LAUNCHER"></category>
            </intent-filter>
        </activity>
-       <activity android:screenOrientation="landscape" android:configChanges="orientation|keyboardHidden" android:name="VncCanvasActivity">
+       <activity  android:configChanges="orientation|keyboardHidden" android:name="VncCanvasActivity">
            <intent-filter>
                <action android:name="android.intent.action.VIEW"></action>
            </intent-filter>
Index: src/android/androidVNC/VncCanvasActivity.java
===================================================================
--- src/android/androidVNC/VncCanvasActivity.java   (revision 204)
+++ src/android/androidVNC/VncCanvasActivity.java   (working copy)
@@ -39,6 +39,7 @@
 import android.graphics.PointF;
 import android.net.Uri;
 import android.os.Bundle;
+import android.os.Handler;
 import android.os.SystemClock;
 import android.util.Log;
 import android.view.KeyEvent;
@@ -607,6 +608,23 @@

        vncCanvas = (VncCanvas) findViewById(R.id.vnc_canvas);
        zoomer = (ZoomControls) findViewById(R.id.zoomer);
+       
+       vncCanvas.setOnSystemUiVisibilityChangeListener(new View.OnSystemUiVisibilityChangeListener(){
+           @Override
+           public void onSystemUiVisibilityChange(int visibility) {
+               if (visibility == View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) return;
+               
+               Handler handler = new Handler();
+               handler.postDelayed(new Runnable() {
+                  @Override
+                  public void run() {
+                       vncCanvas.setSystemUiVisibility(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
+                   }
+               }, 3000);
+           }
+       });
+       vncCanvas.setSystemUiVisibility(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
+

        vncCanvas.initializeVncCanvas(connection, new Runnable() {
            public void run() {

プロジェクトの「プロパティ」から「Android」を選んでターゲットを 4.2.2 にします。

これでビルドできるはずです。
尚、「自動でビルド」のチェックは何故か必須です。


プロフィール
20年勤めた会社がリーマンショックで消滅、紆余曲折を経て現在はフリーランスのSE。 失業をきっかけにこのブログを始める。

サイト内検索

登録
RSS/2.0

カテゴリ

最近の投稿【nexus7】

リンク

アーカイブ