Help us understand the problem. What is going on with this article?

iOSでSuicaの履歴を読み取る

WWDC2019で発表されたように、iOS13からCoreNFCがFeliCaの読み書きに対応しました 🎉
FeliCaといえばSuicaなどの交通系ICカードの履歴を読み取るのがお約束かと思うので試してみました。

この記事はiOS13のbeta3の時点でAppleが公開しているWWDCのセッションやドキュメントを元に構成しています。よって、今後は仕様などが変わる可能性があるのでご注意ください。また、NDAに配慮してスクリーンショットやプロジェクトの公開はしていませんので、あらかじめご了承ください。

環境

必要な動作環境です。

  • Xcode11.0(beta)
  • iOS13(beta)

なお、macOSはCatalinaへあげなくてもOKでした。

実際にFeliCaを読み込むには、以下のものが追加で必要となります。

  • Apple Developer Program(無料アカウント不可)
  • FeliCa対応 iPhone(iPhone7 / iPhone 7 Plus 以降)

なお、読み取りたい交通系ICカードはApple WatchのSuicaでも大丈夫です。
ただし、「ダブルクリックで支払い」から「リーダにかざす」という状態でにしないと読み取れません。

今回の記事執筆時点での動作確認済みの環境は以下のとおりです。

  • iPhone7 Plus / iOS13 beta3
  • macOS 10.14.6 / Xcode 11.0 beta3
  • AppleWatch / ICOCA / PiTaPa

実装

事前準備

CoreNFCを使う為に以下の2つの設定を行います。

Capabilityの追加

  • Xcodeのプロジェクト設定の「Capabilities」で「Near Field Communication Tag Reading」を追加
    (Xcode11では「Capabilities」がこれまでの画面とはちょっと違っています)

なお、無料アカウントではこのNFCの読み取りの設定が追加できないようになっています。。。

Info.plistの設定

  • Privacy - NFC Scan Usage Description

    • NFCの利用用途を記述
  • ISO18092 system codes for NFC Tag Reader Session

    • Arrayの1つ目に「0003」と入力

1つ目は位置情報やプッシュ通知と同じく、NFCを利用しようとした時にユーザへ表示される許可画面の文言です。

2つ目にはアプリがどのシステムコード1を読み取りたいかを設定します。今回はSuicaのシステムを読み取りたいので「0003」を指定します。
ここで指定したシステムコードを持つタグ(カード)のみがCoreNFCでの検出対象となります。
注意点としては、ワイルドカードが使えないことと、plistに設定していないシステムコードは使えないことです。つまり、使いたいシステムコードは事前に全てplistに追加しておく必要があります。

読み取りのシーケンス

通常のリーダでSuica(交通系ICカード)の履歴データを取得する場合は以下の流れになります。

  1. システムコードで0003を指定してPollingコマンドを実行し近くのSuicaを検索
  2. Suicaから返ってきたレスポンスからIDm2を取得
  3. Request Serviceコマンドで指定したサービス(履歴データ)がカード上に存在するか確認
  4. Read Without Encryptionコマンドで履歴データを読み込む

CoreNFCの場合だと以下の流れになります。

  1. NFCTagReaderSessionを生成してセッションを開始
  2. タグが検出されればデリゲートに通知がくる
  3. タグに接続する(以降、FeliCaのコマンドが実行可能)
  4. Request Serviceコマンドで指定したサービス(履歴データ)がカード上に存在するか確認
  5. Read Without Encryptionコマンドで履歴データを読み込む

1のセッションを開始すると、FeliCaでいうところのポーリングが始まります。アプリ上ではこのタイミングで専用UIがiOSから表示されます。
FeliCaと違うのは、3のタグへの接続という一手間が必要な点です。実は2の時点でFeliCaタグというのは確定しているのですが、接続せずにいきなりFeliCaコマンドを実行するとエラーになります。
接続さえできてしまえば、後は通常の流れと同じようにFeliCaのコマンドを使えます。

コマンドもよく使いそうなもの(Request ServiceRead / Write Without Encryptionなど)は専用のメソッドが準備されており、それ以外もsendFeliCaCommandというメソッドで直接バイナリのパケットを使ってコマンドを送れるようになっています。
また、それぞれのレスポンスはFeliCaコマンド実行時のレスポンス+CoreNFCのエラーとなっていますので、既存のFeliCaの実装からの移植も難しくないかと思います。
ただ、現時点ではAppleの公式ドキュメントに詳細が載っていないので、実装時はFeliCaの仕様書の方を参考にしましょう。

実装

FeliCaデータの読み込み

今回はサンプルなのでViewControllerに直接実装する前提でのコードです。
まず最初にCoreNFCをインポートします。

import CoreNFC

次にセッションを生成して開始します。

if let session = NFCTagReaderSession(pollingOption: .iso18092, delegate: self) {
    // NFC読み取り時の専用UIの中に表示される文言を設定
    session.alertMessage = "Suicaをかざしてください"
    // セッションの開始
    session.begin()
} else {
    print("Error")
}

pollingOptionにはFeliCaを読みとりたいので.iso18092を指定しておきます。

セッションを開始したら、そのイベントを受け取るためにNFCTagReaderSessionDelegateを実装します。必要なメソッドは以下の3つです。

func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}

func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {}

func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {}

それぞれのメソッドは基本的には名前どおりのタイミングで呼ばれますが、didInvalidateWithErrorはエラーだけでなく読み取りが成功して意図的に閉じた場合にも呼ばれるので注意が必要です。

タグ(Suica)が検出できるとdidDetectが呼ばれるのでここにFeliCaの読み込み処理を実装していきます。

func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
    guard let tag = tags.first, case let .feliCa(felicaTag) = tag else { return }    // ①

    session.connect(to: tag) { error in    // ②
        if let error = error {
            print("Error: ", error)
            return
        }

        let historyServiceCode = Data([0x09, 0x0f].reversed())    // ③
        felicaTag.requestService(nodeCodeList: [historyServiceCode]) { nodes, error in    // ④
            if let error = error {
                print("Error: ", error)
                return
            }

            guard let data = nodes.first, data != Data([0xff, 0xff]) else {    // ⑤
                print("サービスが存在しない")
                return
            }

            let blockList = (0..<12).map { Data([0x80, UInt8($0)]) }    // ⑥
            felicaTag.readWithoutEncryption(serviceCodeList: [historyServiceCode], blockList: blockList)    // ⑦
            { status1, status2, dataList, error in          
                if let error = error {
                    print("Error: ", error)
                    return
                }
                guard status1 == 0x00, status2 == 0x00 else {    // ⑧
                    print("ステータスフラグエラー: ", status1, " / ", status2)
                    return
                }
                session.invalidate()    // ⑨

                // ここでデータ(dataList)の解析処理
                ...
            }
        }
    }
}

まず最初に検出したタグをFeliCaタグ(felicaTag)として抽出します(①)。なお、この時点でもfelicaTag.currentIDmとすればIDmは取得可能ですので、IDmだけ欲しい場合はかなり簡単に取得できます。

タグを取り出したらconnect(②)で接続します。接続できればFeliCaコマンドのRequest Service(④)を実行します。

ここで注意しないといけないのがサービスコード3の定義(③)です。サービスコードは2byteのデータですが、リトルエンディアンでの指定が必要です。
今回読み取りたいSuicaの履歴(乗降履歴情報)のサービスコードは「090F」ですので、リトルエンディアンだと0F 09のデータとなります。それで、reverseを使ってバイトの並び順を入れ替えてリトルエンディアンにしています。

Request ServiceのレスポンスがFF FFであればサービスは存在しているので、データを読み込みにとりかかります。

FeliCaではデータを読み込み時は、サービスコードとその中のどのブロックを読み込むのかという指定が必要です。サービスには格納されているデータ量に応じてブロックが割り当てられています。
例えば、乗降履歴情報のサービスだと20ブロックが割り当てられています。1ブロックが履歴1件に対応しているので、履歴は20件まで参照できることになります。

どのブロックを読み込むのかという指定はブロックリストと呼ばれるパケットデータで指定します。詳細なフォーマットは仕様書を参照して頂くとして、今回は、先頭の1byteのフォーマットなどを指定する部分は0x80固定、続く何番目のブロックを読み込むかを指定する部分は0から11までの連番で12個分(※なぜ12個なのかは後述)でブロックリストを作成しています(⑥)。

読み込みたいサービスコードとブロックのリストを指定してRead Without Encryptionコマンドを実行(⑦)すると、読み込み結果が返ってきます。指定どおりにデータが読めたかどうかはステータス1とステータス2をチェックして判断します(⑧)。もし、指定したサービスコードやブロック番号が間違っていたりすると、このステータスにエラーコードが入ります。

データが読み込めたら、セッションを閉じます(⑨)。セッションを閉じると、表示されていた専用UIが完了のチェックマークにアニメーションしてから非表示になります。

ブロック数が12個の理由

乗降履歴は20件あるのでブロックリストで20ブロックを指定すれば良いはずなのに、12個しか指定していないのはなぜか?というと、13個以上を指定するとステータスがエラーで返ってくるからです。

エラーコードの内容や2回に分けると正常に読めることから、最大同時読み出しブロック数が12であると推測されます。
(FeliCaの仕様としては「同時読み出し可能なブロック数の最大値は製品ごとに異なります。」)

ただ、この制約がiOS側(CoreNFC)に起因するのかハード側(iPhone)に起因するのかまでは検証できませんでした。案外、ベータが上がったりiPhoneをもっと新しい機種にしたら読めるかもしれません。

履歴の解析

データが取得できたらそのバイナリデータを解析していきます。
Suicaの履歴情報については公式には公開されていませんが、有志の方々の解析結果があるのでそれを元に履歴データへ変換します。
(この辺りの詳細は参考資料がわかりやすいと思います)

dataList.forEach { data in
    print("年: ", Int(data[4] >> 1) + 2000)
    print("月: ", ((data[4] & 1) == 1 ? 8 : 0) + Int(data[5] >> 5))
    print("日: ", Int(data[5] & 0x1f))
    print("入場駅コード: ", data[6...7].map { String(format: "%02x", $0) }.joined())
    print("出場駅コード: ", data[8...9].map { String(format: "%02x", $0) }.joined())
    print("入場地域コード: ", String(Int(data[15] >> 6), radix: 16))
    print("出場地域コード: ", String(Int((data[15] & 0x30) >> 4), radix: 16))
    print("残高: ", Int(data[10]) + Int(data[11]) << 8)
}

とりあえず今回は検証が目的だったので上記コードだけですが、実用的にするなら駅コードを駅名に変換したり、利用種別や入出場種別も解析して乗降履歴なのか他の物販なのかなどの取得が必要になるでしょう。

ハマりポイント

今回試してハマったポイントや注意点です。

iPhoneのNFC読み取り位置

これは半日ぐらいハマりました。。。
公式サンプルを元に実装した後、いくら試しても読み取れないと思っていたら、単にカードを当てる位置を間違っていたというやつです。
正しいiPhoneのNFCの読み取り位置は本体の裏側上端(フロントカメラやスピーカーの裏側)にあります。
なので、Androidスマホみたいに本体の裏側の真ん中とかに当てると全く読み取りません。

リトルエンディアン

サービスコードなどでも出てきましたが、パケットデータにリトルエンディアン指定が入るものがあります。
FeliCaの仕様書にはちゃんと明記されているので、面倒だけど仕様書をちゃんと見る必要があります。
(履歴データでも残高部分がリトルエンディアンです)

コールバック地獄

今回はサンプルということでエラー処理周りなど省略したのでまだシンプルですが、ここにエラー時のリカバリ処理などを追加していくとあっという間に非同期処理のコールバック地獄ができあがります。

それで、本格的に実装する場合はPromiseKitのようなPromiseのライブラリの導入をオススメします。
(RxSwiftのようなRxライブラリを採用しているならそれでも良いです)

制約がある

この記事の中でも触れましたが、CoreNFCにはいくつか制約があります。

  • 必ず専用のUIが出る
    独自のUIを使ったりバックグラウンドで読み込んだりできない
    ボタンを押している間だけポーリングするというような細かな制御ができない

  • 狙い撃ち(NFCのタイプやシステムコードを事前に指定)したタグしか読めない
    とりあえずタグを読ませてから、NFC-AなのかFelicaなのかを判定するようなことができない
    タグを高速に連続で読むといったことができない

ただ、よっぽど凝ったアプリを作ろうとしない限りは問題とはならないでしょうし、ユーザ観点からすると専用UIの方が親切だと思います。

感想

CoreNFCでFeliCaを触ってみた感じとしては、CoreNFCとうまく融合していて親切なフレームワークの作りだと思いました。

例えば、Read Without Encryptionのコマンドでいうと、専用のメソッドが準備されていて、サービスコードやブロックリストを配列で渡せるようになっており、結果のデータも配列で返ってきます。これがFeliCaのコマンドを単にラップしたようなSDKだと、バイナリデータを渡してバイナリデータが返ってくるみたいな作りなので、使いやすくする為に自分で薄いラッパを書かないといけなかったりします。逆に抽象化されすぎた癖のあるライブラリだと、ライブラリ自体の使い方を覚えないといけない上にトラブった時の原因究明が大変だったりします。

その点、CoreNFCは程よくラップされていて、スッキリと自然な流れで実装できました。
あとはasync / awaitがSwiftに導入されれば、ここにリトライやエラー処理が入ってもかなりシンプルに実装できるので今後が楽しみです。

参考資料


  1. 事業者(サービス)や使用目的ごとに決められているコード。通常のFeliCaであればポーリング時に指定するもの。1つのカードに複数のシステムを持つことができ、交通系ICカードだと0003以外にFelicaの共通領域としてFE00のシステムコードも持っている。 

  2. カードを識別する為のID。いわゆるMACアドレスのようなもの。読み書きなどのコマンド実行時にIDmを指定することで特定のカードに対して通信を行える。よくFeliCaカードを社員証などのIDカードとして使う時にIDとして使用されているが実はそれほどセキュアではなかったりする。。。 

  3. FeliCaのメモリ上のデータ領域はブロックと呼ばれる16byteの領域が最小単位となっている。このブロックをグループ化したものをサービスといい、そのサービスに割り振られたIDがサービスコードである。PCにざっくり例えると、ブロックが1つのファイル、サービスはそのファイルを格納したフォルダ、サービスコードがフォルダ名となる。 

m__ike_
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした