0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Kotlin】ICカードの利用履歴や残高を読み取る

Last updated at Posted at 2020-07-15

はじめに

Felica規格のカードから利用履歴を読み取るプログラムを書いていきます。
まずは読み取れたデータを解析して少し使いやすくするまで作っていきます。

前回の記事の準備部分を参考にそこまでは進めておく必要があります。
前回の記事

前回の続き

sample.kt
val nfc : NfcF = NfcF.get(tag) ?: return
nfc.connect()
nfc.close()

前回は上記のように接続と切断まで進めていきました。tagからカードIDも取得しました。
NfcFにはtransceiveという送受信してくれるメソッドがありますのでそれを使います。

送信データの構造

送信データをバイト型配列で作ります。

インデックス 意味
0 0xnn データ長
1 0x06 カード内データを読むコマンド
2..9 tag.idの値
10 0x01
11 0x0f カード内読み取り位置の下位8ビット
12 0x09 カード内読み取り位置の上位8ビット
13 0x01 読み取るブロック数
14 0x80
15 0..15 ブロック番号
0x900fはICカードの履歴が書き込まれているエリアです。
ブロック数はとりあえず16件読みます。

受信データの構造

履歴を読み込んだときは29バイトのデータが返ってきました。
受信データのインデックス値13以降が受信データのようです。
ICカードのデータ構造は非常に複雑ですのでこちらのサイトを参考にして下さい。

IC SFCard Fan

地域コードと路線コードと駅コード、とりあえずこの3つのデータがあれば上記のサイトを参考にしてローカルなデータベースから取得することが出来ます。
※そこまで公開はしません。
4つめのデータ、電車なのかバスなのか、お店なのかも処理を分岐させると細かい情報を得られます。

残高と利用金額

Balanceキーとして保存した値は残高です。インデックス0が最新なので StationList[0]["Balance"]が残高になります。
インデックスが+1のBalanceとの差額が利用金額になります。
最新の残高が 400円でその1つ前のデータが600円だとしたら 600円 - 400円で 200円が利用料金になります。
式にするとこうですね。
StationList[1]["Balance"] - StationList[0]["Balance"]
ただしProcCodeが0x02,0x07,0x14,0x49の場合はチャージなので計算するとマイナスになります。
利用料金やチャージの表示でマイナスを表記することは無いので**abs()**で絶対値にしてしまいましょう。

処理コード

支払いなのかチャージなのかを判断するのはProcCodeを使います。
代表的な処理コードは下記の通りです。

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

機器コード

ICカードを対応した機械の種別はMatineCodeに機器コードとして入っています。
代表的な機器コードは下記の通りです。

意味
3 清算機
4 携帯型端末
5 車載端末
7 券売機
8 券売機
9 入金機
18 券売機
20 券売機等
21 券売機等
22 改札機
23 簡易改札機
24,25 窓口端末
26 改札端末
27 携帯電話
28 乗継精算機
29 連絡改札機
31 簡易入金機
70 VIEW ALTTE
72 VIEW ALTTE
199 物販端末
200 自販機

駅名や店名

駅名や店名を簡単に取得する方法はありません。ありませんがどのようにして取得するのかを記載しておきます。
InCodeが入った改札の路線コードが、InStationには入った改札が設置されている駅コードが格納されています。
この2バイトで65536通りが作られますが、それでは足りませんので拡張されています。
RegionCodeにも地域のコードが入っているのですが、これを使って路線やお店の地域コードAreaCodeを作成します。

RegionCodeの値 InCodeの値 AreaCode 意味
0 最上位ビットが0 0 JR線
0 最上位ビットが1 1 関東私鉄
0以外 2 関西私鉄

ProcCodeMatinecodeで処理は変わりますが
ProcCode=1 運賃支払い
Matinecode = 22 改札機
AreaCode = 0 JR線
InCode = 1 JR東日本 東海道本線
InStation = 1 東京駅
OutCode = 1 JR東日本 東海道本線
OutStation = 2 有楽町駅
ですので東京駅から乗って有楽町駅で降りたという意味になります。

ProcCode = 70 物販
Matinecode = 199 物販販売
AreaCode = 1 関東私鉄
RegionCode = 199
OutCode = 26 ジャスコ
OutStation = 52 東雲店 1F食料品レジ8番レジ
ProcCode 70は物販なのでInCodeとInStationの2バイトで時刻が表されています。
2バイトでの時刻表現は上から5ビット分が、6ビット分が、残り5ビット分がになります。
秒が0~29までの表現しか出来ないので2秒単位の値になります。

なぜ、この値でこの場所になるのかは解析サイトを参考にして下さい。

ソース

いつものごとく複数ファイルで構成されているものをまとめてソースとしています。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    val felica = FelicaReader(this, this)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        felica.setListener(felicaListener)
    }
    override fun onResume() {
        super.onResume()
        felica.start()
    }
    override fun onPause() {
        super.onPause()
        felica.stop()
    }

    private val felicaListener = object : FelicaReaderInterface{
        override fun onReadTag(tag : Tag) {                     // データ受信イベント
            val tvMain = findViewById<TextView>(R.id.tvMain)
            val idm : ByteArray = tag.id
            tvMain.text = byteToHex(idm)
            Log.d("Sample",byteToHex(idm))
        }

        override fun onConnect() {
            Log.d("Sample","onConnected")
            for (i in 0..felica.StationList.size-1){
                Log.d("Sample","${felica.StationList[i]}")
            }
        }
    }

    private fun byteToHex(b : ByteArray) : String{
        var s : String = ""
        for (i in 0..b.size-1){
            s += "[%02X]".format(b[i])
        }
        return s
    }
}
interface FelicaReaderInterface : FelicaReader.Listener {
    fun onReadTag(tag : Tag)                        // タグ受信イベント
    fun onConnect()
}

class FelicaReader(private val context: Context,private val activity : Activity) : android.os.Handler() {
    private var nfcmanager : NfcManager? = null
    private var nfcadapter : NfcAdapter? = null
    private var callback : CustomReaderCallback? = null

    private var listener: FelicaReaderInterface? = null
    interface Listener {}

    var StationList : MutableList<MutableMap<String,Any>> = mutableListOf()

    fun start(){
//        dataFromFile("test.txt")
//        analysis()
//        listener?.onConnect()
        callback = CustomReaderCallback()
        callback?.setHandler(this)
        nfcmanager = context.getSystemService(Context.NFC_SERVICE) as NfcManager?
        nfcadapter = nfcmanager!!.getDefaultAdapter()
        nfcadapter!!.enableReaderMode(activity,callback
            ,NfcAdapter.FLAG_READER_NFC_F or
                    NfcAdapter.FLAG_READER_NFC_A or
                    NfcAdapter.FLAG_READER_NFC_B or
                    NfcAdapter.FLAG_READER_NFC_V or
            NfcAdapter.FLAG_READER_NO_PLATFORM_SOUNDS or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK,null)
    }
    fun stop(){
        nfcadapter!!.disableReaderMode(activity)
        callback = null
    }

    fun dataToFile(filename : String){
        val writeFile = File(context?.filesDir, filename)
        val bw = writeFile.bufferedWriter()
        bw.write(Gson().toJson(StationList))
        bw.close()
    }

    fun dataFromFile(filename : String){
        StationList.clear()
        val readFile = File(context?.filesDir, filename)
        var Station = mutableMapOf<String,Any>()
        if(!readFile.exists()) return
        val br = readFile.bufferedReader()
        var str : String? = null
        while (true){
            str = br.readLine()
            if (str == null) break
            Station = Gson().fromJson<MutableMap<String,Any>>(str, MutableMap::class.java) as MutableMap<String,Any>
            StationList.add(Station)
        }
        br.close()
    }

    override fun handleMessage(msg: Message) {                  // コールバックからのメッセージクラス
        if (msg.arg1 == 1){                                     // 読み取り終了
            StationList.clear()
            listener?.onReadTag(msg.obj as Tag)                 // 拡張用
        }
        if (msg.arg1 == 3){                                     // 読み取り終了
            StationList = callback!!.StationList
            dataToFile("test.txt")
            analysis()
            listener?.onConnect()                               // 拡張用
        }
    }

    fun setListener(listener: FelicaReader.Listener?) {         // イベント受け取り先を設定
        if (listener is FelicaReaderInterface) {
            this.listener = listener as FelicaReaderInterface
        }
    }

    private fun analysis(){
        analysisMoney()
        analysisProcCode()
        analysisMatineCode()
    }

    private fun analysisMoney(){
        for (i in 1..StationList.size-1){
            val Station1 = StationList[i]
            val Station2 = StationList[i-1]
            val b1 : Int = Station1.get("Balance").toString().toDouble().toInt()
            val b2 : Int = Station2.get("Balance").toString().toDouble().toInt()
            Station2["MoneySpent"] = abs(b1 - b2)
        }
    }

    private fun analysisProcCode(){
        for (i: Int in 0..StationList.size-1) {
            val Station: MutableMap<String, Any> = StationList[i]
            val s: String = when (Station.get("ProcCode").toString().toDouble().toInt()) {
                1    ->   "運賃支払"         //(改札出場)
                2    ->   "チャージ"         //
                3    ->   "券購入"           //(磁気券購入)
                4    ->   "精算"             //
                5    ->   "入場精算"         // (入場精算)
                6    ->   "改札窓口処理"     // (改札窓口処理)
                7    ->   "新規発行"         // (新規発行)
                8    ->   "窓口控除"         // (窓口控除)
                13   ->   "バス(PiTaPa系)"   // (PiTaPa系)
                15   ->   "バス(IruCa系)"    // (IruCa系)
                17   ->   "再発行処理"       // (再発行処理)
                19   ->   "新幹線利用"       // (新幹線利用)
                20   ->   "入場時チャージ "  //(入場時オートチャージ)
                21   ->   "出場時チャージ"   //(出場時オートチャージ)
                31   ->   "バスチャージ"     // (バスチャージ)
                35   ->   "券購入"           // (バス路面電車企画券購入)
                70   ->   "物販"             //
                72   ->   "特典"             // (特典チャージ)
                73   ->   "入金"             // (レジ入金)
                74   ->   "物販取消"         //
                75   ->   "入場物販"         // (入場物販)
                198  ->   "現金併用物販"     // (現金併用物販)
                203  ->   "入場現金併用物販" // (入場現金併用物販)
                132  ->   "他社精算"         // (他社精算)
                133  ->   "他社入場精算"     // (他社入場精算)
                else ->"その他"
            }
            Station["ProcCodeName"] = s
        }
    }
    private fun analysisMatineCode(){
        for (i: Int in 0..StationList.size-1) {
            val Station: MutableMap<String, Any> = StationList[i]
            val s: String = when (Station.get("MatineCode").toString().toDouble().toInt()) {
                3     ->  "清算機"
                4     ->  "携帯型端末"
                5     ->  "車載端末"
                7     ->  "券売機"
                8     ->  "券売機"
                9     ->  "入金機"
                18    ->  "券売機"
                20    ->  "券売機等"
                21    ->  "券売機等"
                22    ->  "改札機"
                23    ->  "簡易改札機"
                24,25 ->  "窓口端末"
                26    ->  "改札端末"
                27    ->  "携帯電話"
                28    ->  "乗継精算機"
                29    ->  "連絡改札機"
                31    ->  "簡易入金機"
                70    ->  "VIEW ALTTE"
                72    ->  "VIEW ALTTE"
                199   ->  "物販端末"
                200   ->  "自販機"
                else  ->  "その他"
            }
            Station["MatineCodeName"] = s
        }
    }

        private class CustomReaderCallback : ReaderCallback {
        var Station = mutableMapOf<String,Any>()
        var StationList : MutableList<MutableMap<String,Any>> = mutableListOf()
        private var handler : android.os.Handler? = null
        override fun onTagDiscovered(tag: Tag) {
            StationList.clear()
            val nfc : NfcF = NfcF.get(tag) ?: return
            try {
                nfc.connect()
                for (i in 0..15) {
                    var b: ByteArray = nfc.transceive(makeRequest(tag.id, i, 0x090f))
                    byteToStation(b)
                }
                nfc.close()
                val msg = Message.obtain()
                msg.arg1 = 3
                msg.obj = tag
                if (handler != null) handler?.sendMessage(msg)
            }catch (e : Exception){
                nfc.close()
            }
        }
        fun setHandler(handler  : android.os.Handler){
            this.handler = handler
        }

        fun makeRequest(cardId : ByteArray,adr : Int,code : Int) : ByteArray{
            val s = ByteArrayOutputStream1()
            s.write(0x00)
            s.write(0x06)
            s.write(cardId)
            s.write(0x01)
            s.write(code and 0xff)
            s.write(code shr 8)
            s.write(0x01)
            s.write(0x80)
            s.write(adr)
            val sb : ByteArray = s.toByteArray()
            sb[0] = sb.size.toByte()
            return sb
        }

        private fun byteToStation(b : ByteArray){
            if (b.size < 29) return
            Station = mutableMapOf()
            Station["MatineCode"] = b[13]                   // 機器コードを取得
            Station["ProcCode"] = b[14]                     // 処理コードを取得
            val date : Int = (b[17].toInt() shl 8) + b[18]  // 日付部分を取得
            Station["Yer"] = (date shr 9) + 2000            // 「年」部分を分解
            Station["Mon"] = (date shr 5) and 0x0f          // 「月」部分を分解
            Station["Day"] = date and 0x1f                  // 「日」部分を分解
            when(Station["ProcCode"]){
                70, 73, 74, 75, 198, 203 ->{
                    val time : Int = (b[19].toInt() shl 8) + b[20]  // 時刻部分を取得(処理によっては取得可能)
                    Station["Hou"] = time shr 11                    // 「時」部分を分解
                    Station["Min"] = (time shr 5) and 0x3f          // 「分」部分を分解
                    Station["Sec"] = (time and 0x1f)*2              // 「秒」部分を分解
                    Station["FTimeEnabled"] = true                  // 時刻を有効とする
                }
            }
            Station["Balance"]    = (b[24].toInt() shl 8) + b[23]  // 残高を取得
            Station["InCode"]     = b[19]                       // 入った駅の路線コード取得
            Station["InStation"]  = b[7]                        // 入った駅の駅コードを取得
            Station["OutCode"]    = b[8]                        // 出た駅の路線コードを取得
            Station["OutStation"] = b[9]                        // 出た駅の駅コードを取得
            Station["RegionCode"] = b[15]                       // 地域コードを取得
            if (Station["RegionCode"] == 0){
                if (Station["InCode"].toString().toInt() < 0x80){
                    Station["AreaCode"] = 0                   // 地域コードを指定(JR線)
                }
                else{
                    Station["AreaCode"] = 1                   // 地域コードを指定(関東私鉄)
                }
            }
            else{
                Station["AreaCode"] = 2                     // 地域コードを指定(関西私鉄)
            }
            StationList.add(Station)
        }
    }
}

おわりに

駅コードから駅の名称変換は自力で作りましょう。
KIOSKのコードだけ実はやたらと細かいのが気になります。同じお店の何番目のレジなのかまで記録する必要があったのでしょうか?
16,777,216パターン作れるので余裕があると思ったのでしょうか?

参考

読み書きのデータ構造は下記サイトを参考にしています。
KyakujinのWarning Log

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?