AndroidとSesameとNFCで開けゴマ BLE-改良版


2020/05/12追記：分割送信データの送信完了確認したら、残りを送信するように修正
　　　　　　　　IDカードの等価式の修正

#目的
家の鍵を「かざ」~~すだけで~~して開けたい！

AndroidとSesameとNFCで開けゴマ BLE版
https://qiita.com/sakujira/items/93998534d8358b387ee9
の動作が不安定すぎたので、改良しました。


##成果物
AndroidManifest.xml　は同じ

kotlinx.coroutinesを使うので、
buid.gradle(app)に以下を追加

	dependencies {
	・・・・
	implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:X.X.X'
	}

MainActivity.kt

	package com.example.myapplication

	//UIのためのおまじない
	import android.os.Bundle
	import androidx.appcompat.app.AppCompatActivity
	//NFCのため
	import android.nfc.NfcAdapter
	//BLEのため
	import android.bluetooth.*
	import android.content.Context
	import android.content.pm.PackageManager
	import android.widget.TextView
	//Byte変換のため
	import kotlin.math.floor
	import java.util.*
	//暗号化のため
	import java.security.MessageDigest
	import javax.crypto.Mac
	import javax.crypto.spec.SecretKeySpec
	//別スレッドで実行するため
	import kotlinx.coroutines.*
	import kotlin.collections.ArrayList

	class MainActivity : AppCompatActivity() {
	    //ユーザー情報
	    val CardID : String = -鍵にしたいCardID-
	    val UserID : String  = -Sesameのメールアドレス-
	    val Password : String  = -公式APPから抜き出したパスワード-
	    val BLEaddress : String = -SesameのBLEアドレス-
	    val manufactreDataMacDataString : String  = -SesameのBluetoothMacアドレス？結局最後までわからず-
	    //施錠・解錠判定用(APIのように状態を教えてくれないので角度から判断する必要があるので
	    val LockMinAngle : Int = 10
	    val LockMaxAngle : Int = 270

	    //BLEの接続に必要なクラスを宣言
	    var adapter: BluetoothAdapter? = null
	    var device: BluetoothDevice? = null
	    var mGatt: BluetoothGatt? = null

	    //送信データ関係
	    var mSendData: ArrayList<ByteArray> = ArrayList()
	    var mSendPointer : Int = 0

	    //SesameがもつBLEのサービス検索詞
	    val ServiceOperationUuid : UUID          = UUID.fromString("00001523-1212-efde-1523-785feabcd123")
	    val CharacteristicCommandUuid : UUID     = UUID.fromString("00001524-1212-efde-1523-785feabcd123")
	    val CharacteristicStatusUuid : UUID      = UUID.fromString("00001526-1212-efde-1523-785feabcd123")
	    val CharacteristicAngleStatusUuid : UUID = UUID.fromString("00001525-1212-efde-1523-785feabcd123")

	    //サービス検索結果：各サービスへの接続詞
	    var CharStatus:BluetoothGattCharacteristic? = null
	    var CharCmd:BluetoothGattCharacteristic? = null
	    var CharAngle:BluetoothGattCharacteristic? = null

	    //状態管理用の変数
	    var CommandState : Int = 0      //次のコマンドを何を投げるかを管理
	    var SesameState : Int  = 0      //Sesame側のカウンターを管理
	    var LockState : Int    = 0      //開けるか・閉めるか・状態を聞くかを管理

	    @ExperimentalUnsignedTypes
	    override fun onCreate(savedInstanceState: Bundle?) {
	        super.onCreate(savedInstanceState)
	        setContentView(R.layout.activity_main)

	        //BLE対応端末かどうかを調べる。対応していない場合は終了
	        if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
	            finishAndRemoveTask()    //成功しなければ、閉じる
	        }

	        //Bluetoothアダプターを初期化する
	        val manager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
	        adapter = manager.adapter
	        if(adapter == null){
	            finishAndRemoveTask()    //成功しなければ、閉じる
	        }

	        //BLE端末一覧を検索せずに事前に調べたSesameに直接接続するように設定
	        println("Btest:Start-Connect")
	        device = adapter!!.getRemoteDevice(BLEaddress)
	        if(device == null){
	            finishAndRemoveTask()    //成功しなければ、閉じる
	        }

	        //このアプリを開く[起因]はNFC情報を読み取り
	        //A1.[起因]は、AndroidManifest.xmlに規定した<intent-filter>に起因する
	        if(NfcAdapter.ACTION_NDEF_DISCOVERED == intent.action) {
	            //カードのID情報を取得
	            var tagId: String = ""
	            for (byte in intent.getByteArrayExtra(NfcAdapter.EXTRA_ID)) {
	                tagId += String.format("%02X", byte) + ":"
	            }
	            //A2.読み込んだカードIDが一致すれば、BLE操作を開始
	            if(tagId == CardID+":"){
	                StateCommand()
	            }else{
	                finishAndRemoveTask()
	            }
	        }else{
	            finishAndRemoveTask()    //設定したカード以外は、閉じる
	        }
	    }

	    //コールバッククラス？(と呼べばいいのか？)：BLEを使ってのSesameからの返信受付
	    @ExperimentalUnsignedTypes
	    private val mGattcallback: BluetoothGattCallback = object : BluetoothGattCallback() {
	        //B0.Sesameへの接続確認
	        override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
	            super.onConnectionStateChange(gatt, status, newState)
	            //B0.接続確立を確認して
	            if (newState == BluetoothProfile.STATE_CONNECTED) {
	                //B0.サービスの検索を開始
	                println("Btest:GattSa-Search")
	                gatt?.discoverServices()
	            }
	        }

	        //B0.サービスの検索完了：結果を分析
	        override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
	            super.onServicesDiscovered(gatt, status)

	            //B0.サービスの検索が成功を確認して
	            if(status == BluetoothGatt.GATT_SUCCESS) {
	                println("Btest:GattSa-OK!")
	                //B0.サービスの一覧表を取得
	                val GattSList: List<BluetoothGattService> = gatt?.services as List<BluetoothGattService>

	                //B0.サービスの一覧表を走査
	                for (GaService: BluetoothGattService in GattSList) {
	                    println("Btest:>" + GaService.uuid.toString())

	                    //B0.事前にserviceOperationUuidと一致したものがあったら、
	                    if (GaService.uuid.equals(ServiceOperationUuid)) {
	                        //B0.サービスが持っている機能・情報の一覧を取得
	                        val GattCList: List<BluetoothGattCharacteristic> = GaService.characteristics
	                        //B0.機能・情報の一覧の走査
	                        for (GaCharacteristic: BluetoothGattCharacteristic in GattCList) {
	                            println("Btest:>>" + GaCharacteristic.uuid.toString())
	                            //B0.Sesameの状態情報取得の接続詞を取得
	                            if (GaCharacteristic.uuid.equals(CharacteristicStatusUuid)){
	                                CharStatus = GaCharacteristic
	                            }
	                            //B0.Sesameのコマンド情報取得の接続詞を取得
	                            if (GaCharacteristic.uuid.equals(CharacteristicCommandUuid)){
	                                CharCmd = GaCharacteristic
	                            }
	                            //B0.Sesameの角度情報取得の接続詞を取得
	                            if (GaCharacteristic.uuid.equals(CharacteristicAngleStatusUuid)){
	                                CharAngle = GaCharacteristic
	                            }
	                        }
	                    }
	                }
	                //B0.走査した結果が全てあるかどうかをチェックし、次の状態に移行
	                if(!(CharStatus == null || CharCmd == null || CharAngle == null)){
	                    NextState()
	                }else{
	                    finishAndRemoveTask()    //取得できなければ、閉じる
	                }
	            }
	        }

	        //接続詞を使っての読み込み依頼した結果を分析
	        override fun onCharacteristicRead(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) {
	            super.onCharacteristicChanged(gatt, characteristic)

	            println("Btest:" + characteristic!!.uuid.toString())
	            when (characteristic.uuid) {
	                //B3.依頼内容が「AngleStatus」であれば
	                CharacteristicAngleStatusUuid -> {
	                    val data: ByteArray = characteristic.value      //データを取得
	                    println("Btest:" + ByteArrayToString(data))     //データをFF:FF形式で表示
	                    val angleRaw = ByteArrayToInt(data.slice(2..3).toByteArray())   //データを切り出して、Byte→Intへ
	                    val angle = floor(angleRaw * 360 / 1024.0)      //角度を計算

	                    //B4.LockStateは、操作コマンドと兼ねているため 1:解錠(操作：施錠) 2:施錠(操作：解錠)と逆になる
	                    LockState = 1;
	                    if (angle < LockMinAngle || angle > LockMaxAngle) {
	                        LockState = 2;
	                    }
	                    println("Btest:Byte:" + ByteArrayToString(data) + ", Angle:" + angle + ", LockStatus:" + (LockState==2))
	                    NextState()//次の状態に移行
	                }
	                //B1.依頼内容が「Status」であれば
	                CharacteristicStatusUuid -> {
	                    val data: ByteArray = characteristic.value          //データの取得
	                    val Sn: Int = ByteArrayToInt(data.slice(6..9).toByteArray()) + 1    //Sesameカウンターを取得
	                    val Err: Int = ByteArrayToInt(data.slice(14..14).toByteArray()) + 1 //エラーコードを取得
	                    //エラーコードリスト
	                    val errMsg = arrayOf(
	                        "Timeout",
	                        "Unsupported",
	                        "Success",
	                        "Operating",
	                        "ErrorDeviceMac",
	                        "ErrorUserId",
	                        "ErrorNumber",
	                        "ErrorSignature",
	                        "ErrorLevel",
	                        "ErrorPermission",
	                        "ErrorLength",
	                        "ErrorUnknownCmd",
	                        "ErrorBusy",
	                        "ErrorEncryption",
	                        "ErrorFormat",
	                        "ErrorBattery",
	                        "ErrorNotSend"
	                    )
	                    println("Btest:Byte:" + ByteArrayToString(data) + ", Sn:" + Sn + ", Err:" + errMsg[Err])
	                    SesameState = Sn    //B1.Sesameカウンタを記録
	                    NextState()//次の状態に移行
	                }
	            }
	        }

	        //B2.B4.送信データの受領確認後、次パケットを送信
	        override fun onCharacteristicWrite( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) {
	            super.onCharacteristicWrite(gatt, characteristic, status)

	            if(status == BluetoothGatt.GATT_SUCCESS){
	                //B2.B4.送信完了したので、ポインタを一つ進める
	                mSendPointer += 1
	                //B2.B4.全部送信し終えたら
	                if(mSendData.size <= mSendPointer){
	                    println("Btest:SendData:Pointer-End")
	                    //B2.B4.送信データを綺麗にしてから
	                    mSendData = ArrayList()
	                    mSendPointer = 0
	                    //次の処理へ
	                    NextState()
	                }else {
	                    println("Btest:SendData:Pointer"+ mSendPointer)
	                    println("Btest:SendData:" + ByteArrayToString(mSendData[mSendPointer]))
	                    //B2.B4.ここも別スレッドで残りを送信！
	                    GlobalScope.launch {
	                        CharCmd?.setValue(mSendData[mSendPointer])
	                        gatt?.writeCharacteristic(CharCmd)
	                    }
	                }
	            }else{
	                //送信エラーが発生したら閉じる
	                finishAndRemoveTask()
	            }
	        }
	    }
	    //ここまでSesameからの通信受付処理

	    //通信を次の状態へ
	    @ExperimentalUnsignedTypes
	    fun NextState(){
	        CommandState += 1

	        //変更点：別スレッドで次のコマンドを実行
	        GlobalScope.launch{
	            StateCommand()
	        }
	    }

	    @ExperimentalUnsignedTypes
	    fun StateCommand(){
	        println("Btest1:Start-" + CommandState)
	        try {
	            when (CommandState){
	                //B0.Sesameに接続を実行
	                0->{
	                    mGatt = device?.connectGatt(this, false, mGattcallback)
	                }
	                //B1.Sesameの状態取得：Sesameカウントを取得
	                1->{
	                    println("Btest:GetStatus")
	                    mGatt!!.readCharacteristic(CharStatus)
	                }
	                //B2.LockState:0を送信しAngleを検知させる
	                2->{
	                    println("Btest:SendData-1:")
	                    //B2.各パラメータから送信データを作成
	                    val PayLoad = CreateSign(LockState,"", Password, manufactreDataMacDataString, UserID, SesameState)
	                    //B2.データをmtuごとに分割
	                    mSendData = SplitByteArray(PayLoad)
	                    mSendPointer = 0
	                    //B2.1回目のデータ送信開始
	                    println("Btest:SendData:Pointer"+ mSendPointer)
	                    println("Btest:SendData:" + ByteArrayToString(mSendData[mSendPointer]))
	                    CharCmd!!.setValue(mSendData[mSendPointer])
	                    mGatt!!.writeCharacteristic(CharCmd)
	                }
	                //B3.角度を取得し、施錠・解錠を取得
	                3->{
	                    println("Btest:GetRange")
	                    mGatt!!.readCharacteristic(CharAngle)
	                }
	                //B4.施錠・解錠コマンドを送信
	                4->{
	                    println("Btest:SendData-2")
	                    val PayLoad = CreateSign(LockState,"", Password, manufactreDataMacDataString, UserID, SesameState)
	                    //B4.データをmtuごとに分割
	                    mSendData = SplitByteArray(PayLoad)
	                    mSendPointer = 0
	                    //B4.1回目のデータ送信開始
	                    println("Btest:SendData:Pointer"+ mSendPointer)
	                    println("Btest:SendData:" + ByteArrayToString(mSendData[mSendPointer]))
	                    CharCmd!!.setValue(mSendData[mSendPointer])
	                    mGatt!!.writeCharacteristic(CharCmd)
	                }
	                //B5.アプリを閉じる
	                5->{
	                    println("Btest:EndConnect!")
	                    mGatt?.disconnect()
	                    finishAndRemoveTask()
	                }
	            }
	            println("Btest1:End-" + CommandState)
	        }catch(ex: Exception) {    //あえてNullPointerException発生させて、閉じる
	            mGatt?.disconnect()
	            finishAndRemoveTask()
	        }
	    }

	    //B2.B4.Sesameに対してのデータを分割(BLEの20バイト制約のため)
	    @ExperimentalUnsignedTypes
	    fun SplitByteArray(pPayload : ByteArray): ArrayList<ByteArray>{
	        val Data : ArrayList<ByteArray> = ArrayList()

	        //送信は20バイトごとに分割
	        //ただ、分割する際には[先頭：01][中間：02][最後:04]と付ける必要がある
	        //なので、一回の送信は19バイトごと
	        for(i in 0..pPayload.size step 19){
	            val wSz = Math.min(pPayload.size-i,19)      //送るデータが最後かどうか？
	            var wCc : Int = 2           //初期値は[中間:02]とする
	            var wBuf : ByteArray  = ByteArray(wSz+1)    //分割データ場所を作成

	            //先頭・最後を判定
	            if(wSz < 19){
	                wCc = 4
	            }else if(i == 0){
	                wCc = 1
	            }

	            //バイト列に分割詞をつける
	            wBuf[0] = wCc.toByte()
	            //送信データからバイト列を切り出し
	            wBuf = ByteArrayCopy(wBuf, 1, pPayload, i,wSz)
	            println("Btest:CutData:" + ByteArrayToString(wBuf))
	            Data.add(wBuf)
	        }
	        return Data
	    }

	    //B2.B4.認証用バイトデータを作成(普段Byteを使わないから、符号あり・なしに振り回された、、、）
	    fun CreateSign(pCode:Int, pPayload: String, pPassword : String, pMacData : String, pUserid : String, pNonce: Int) : ByteArray{
	        //バイト配列の場所を作成
	        var wBufnonPw : ByteArray = ByteArray(59 - 32 + pPayload.toByteArray().size)

	        //manufactreDataのデータをコピー
	        val ByteMacData : ByteArray = HexStringToByteArray(pMacData)
	        println("Btest:Mac:" + ByteArrayToString(ByteMacData.sliceArray(3..ByteMacData.size-1)))
	        wBufnonPw = ByteArrayCopy(wBufnonPw, 0, ByteMacData.sliceArray(3..ByteMacData.size-1),0,6)

	        //md5のデータをコピー
	        val md5 = MessageDigest.getInstance("MD5").digest(pUserid.toByteArray())
	        println("Btest:md5:" + ByteArrayToString(md5))
	        wBufnonPw = ByteArrayCopy(wBufnonPw,6,md5,0,16)

	        //Status(Nonce)をコピー
	        println("Btest:Nonce:" + ByteArrayToString(InttoByteArrayUnsign(pNonce)))
	        wBufnonPw = ByteArrayCopy(wBufnonPw,22,InttoByteArrayUnsign(pNonce),0,4)

	        //Codeをコピー
	        println("Btest:Code:" + ByteArrayToString(InttoByteArrayUnsign(pCode)))
	        wBufnonPw = ByteArrayCopy(wBufnonPw,26,InttoByteArrayUnsign(pCode),0,1)

	        //Payloadをコピー
	        wBufnonPw = ByteArrayCopy(wBufnonPw, 27, pPayload.toByteArray(),0, pPayload.toByteArray().size)

	        //パラメータの結果を確認
	        println("Btest:PrameterOK!:" + ByteArrayToString(wBufnonPw))

	        //「生成したパラメータ」を「パスワード」を使って暗号化
	        //「パスワード」を使用する暗号機を作成
	        val key = SecretKeySpec(HexStringToByteArray(pPassword), "HmacSHA256")
	        val mac = Mac.getInstance("HmacSHA256")
	        mac.init(key)
	        val wBufKey = mac.doFinal(wBufnonPw)
	        println("Btest:wBufkey:" + ByteArrayToString(wBufKey))

	        //全部を連結
	        var wBuf : ByteArray = ByteArray(pPayload.toByteArray().size + 59)
	        wBuf = ByteArrayCopy(wBuf,0, wBufKey,0, 32)
	        wBuf = ByteArrayCopy(wBuf,32, wBufnonPw,0, wBufnonPw.size)
	        println("Btest:ALL:" + ByteArrayToString(wBuf))

	        return  wBuf
	    }

	    //Intを符号なしのバイト列に変換
	    fun InttoByteArrayUnsign(num : Int): ByteArray{
	        val wHexString : String = num.toString(16).padStart(12,'0')//文字埋めを12桁にしているのはByteArrayCopyで参照値外がないようにするため
	        val wResult = HexStringToByteArray(wHexString)
	        return  wResult.reversedArray() //1101→03F3となるが、送信データ上ではF303と逆にする必要がある
	    }

	    //HEX文字列をバイト配列にキャスト
	    fun HexStringToByteArray(pHexString: String): ByteArray {
	        val wBArray = ByteArray(pHexString.length / 2)
	        for (index in 0 until wBArray.count()) {
	            val pointer = index * 2
	            wBArray[index] = pHexString.substring(pointer, pointer + 2).toInt(16).toByte()
	        }
	        return wBArray
	    }

	    //Byte配列を指定位置にコピー
	    fun ByteArrayCopy(pTarget: ByteArray, pPosition: Int, pCopy: ByteArray, pStart: Int, pLength : Int):ByteArray{
	        for(i in 0 until pLength){
	            pTarget[pPosition + i] = pCopy[pStart + i]
	        }
	        return pTarget
	    }

	    //Byte配列を文字列化
	    fun ByteArrayToString(pBytes: ByteArray): String{
	        var wRsult : String = ""
	        for (b in pBytes) {
	            wRsult += String.format("%02X", b) + ":"
	        }
	        return wRsult
	    }

	    //Byte配列を数値化
	    @ExperimentalUnsignedTypes
	    fun ByteArrayToInt(pBytes: ByteArray): Int {
	        var wResult: Int = 0
	        for (b in pBytes.reversed()){
	            wResult = wResult shl 8
	            wResult += b.toUByte().toInt()
	        }
	        return wResult
	    }
	}

##変更点
ループでの運用を止めて、各処理が終わる度に次の処理を行うように修正。
ただ、次のメソッドを何もせずに実行すると処理が止まってしまう。

推測で、@odetarou様が仰っていた
>「当初はここでコールバックメソッドを呼ぶ形にしようとしたが、ここでlockを呼ぶとpRemoteCharacteristic->canNotify()が動かなかった。ここ自体がBLEのコールバック処理なのでその中で新たなBLEのread, write処理はまずいのかもしれない。」

から、
「コールバックメソッドでのスレッドのままでは、次のコマンドは実行出来ない」
と考えて、
「次のメソッドを別スレッドで実行すればいいのでは？」
と考えて、実装すれば結果が返ってくることに。（NextState()のGlobalScope.launchの部分

実行履歴から、呼び出し元のコールバックメソッドは最後に次のメソッドを呼んでいるので先にコールバックメソッドが終わって、次のコマンドが実行されるといった流れになってくれた（模様、、、）

###今後の課題としては
・機器との接続操作、マルチスレッドの例外処理がうまくないので、もう少しどうにかしたいところ
・機器パスワードをハードコーディングで持っているのもどうかと思うので、keystoreに一応保存したい

###編集後記
一応８割くらいで施錠解錠してくれる模様。あんまり連続で施錠解錠すると遅くなったり動かなくなるので、日常テストをしつつ修正かなと考えてます。

操作感覚はアプリの画面から操作する時間と同じ位ですかね。遅かったり、早かったりと言ったところです。
ただ、金属製のドアにカードを貼り付けているからか、ちょっと不安定？（アプリだとドアから離れて使うことが多いので、、、、

開祖さまの@odetarou様のNODE版のページに行ったら、話題にあげていただいてた！ありがとうございます！！

###編集後記２
一回の送信バイト数：MTUを変更して送信しようとしてみたのですが、MTUの変更は可能、ですが、送信データを送ると受信できない様子でした。（なぜだ～！
この変更ができれば、送信４回が送信３回になるので高速化と安定化出来そうなのにな、、、、（Androidでバグがあるとかの記事も見た気がするからどうにもならないかも？

そのついでに送信データの送信待ちも受信完了確認に合わせて送信する様に変更。速さと安定度が増した気がします。
感じとしては、ほぼほぼ公式APPで画面を押した→施錠・解錠の感覚ですかね。

よし！満足。