iPhone で FeliCa を読み取るライブラリを作りました

この記事は [potatotips #64](https://potatotips.connpass.com/event/139008/) で発表した内容をテキスト化したものです。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">今から発表する資料ですー<br>iPhoneでFeliCaを読み取ってみた<a href="https://t.co/kX4j3SZ6QL">https://t.co/kX4j3SZ6QL</a><br>作ったライブラリ<a href="https://t.co/gnz0eoNqcf">https://t.co/gnz0eoNqcf</a><a href="https://twitter.com/hashtag/potatotips?src=hash&amp;ref_src=twsrc%5Etfw">#potatotips</a></p>&mdash; たなたつ (@tanakasan2525) <a href="https://twitter.com/tanakasan2525/status/1166308963631058946?ref_src=twsrc%5Etfw">August 27, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## TL;DR
サクッと FeliCa の IC カードを読み取れるライブラリを作ったよ。
今のところ下記のカードに対応してるよ。

- Suica, Pasmo, Kitaca, ICOCA, TOICA、manaca、PiTaPa、nimoca、SUGOCA、はやかけん
- nanaco、Edy、WAON
- カスタムタグ

https://github.com/tattn/NFCReader


# iOS 13 から FeliCa の読み書きができるように

WWDC 19 で CoreNFC で FeliCa が読み書きできるようになったことが発表されました。
FeliCa は Suica、Pasmo などの交通系 IC や nanaco、WAON などの電子マネーが採用している非接触 IC カードの技術方式です。

https://developer.apple.com/videos/play/wwdc2019/715/

# Suica を例に読み取り方を紹介

例として Suica の乗降履歴の読み取り手順を紹介します。

## CoreNFC で FeliCa を読み込むときのフロー

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91631/46c3f833-f63a-3e05-a836-f13762a593e7.png)

https://www.sony.co.jp/Products/felica/business/tech-support/index.html
https://developer.apple.com/documentation/corenfc

## プロジェクト設定

Info.plist に Privacy 設定と System Code の追加が必要です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91631/8a3efd78-5795-2a78-20db-2cdee2152f89.png)

System Code はシステムごとに割り当てられた 2 バイトのコードです。 plist に追加していないカードは読み取ることができません。
Suica の場合は 0003 になります。

また、Capabilities に  Near Field Communication Tag Reading を追加する必要があります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91631/19b3b203-1c70-ebd6-a3d9-8006357ea472.png" width="300px" />

## Session の作成 & ポーリングの開始

```swift
let session = NFCTagReaderSession(
    pollingOption: NFCTagReaderSession.PollingOption.iso18092, 
    delegate: self
)
session.alertMessage = "iPhoneをSuicaに近づけてください"
session.begin()
```

FeliCa を読み込む時は ISO18092 を指定します。これは PollingOption のドキュメントコメントにも記載されています。

`alertMessage` を指定すると、読み込みが始まったタイミングで表示される View にそのテキストが表示されます。
`begin` を呼び出すと、タグの読み取り (ポーリング) が開始されます。

## タグとの接続

```swift
public func tagReaderSession(_ session: NFCTagReaderSession, 
                             didDetect tags: [NFCTag]) {

    guard case .feliCa(let tag) = tags.first else { return }

    session.connect(to: tag) { error in
        guard error == nil else { return }

        // Tagの読み込み (次のセクションに続く)
    }
}
```

タグが検出されると、delegate (`NFCTagReaderSessionDelegate`) が呼ばれます。
タグは同時に複数検出される場合がありますが、今回はその時のハンドリングは省略します。

`NFCFeliCaTag` が取得できたら、`connect` メソッドを呼び出して、タグに対してコマンドを送れるようにします。

## Suica のブロックデータの読み込み

```swift
let serviceCodeList = [Data([0x0f, 0x09])]               // サービス(データ)を特定するコード
let blockList = (0..<UInt8(10)).map { Data([0x80, $0]) } // データの取得方法/位置を決める

tag.requestService(nodeCodeList: serviceCodeList) { nodes, error in
    guard error == nil, nodes.first != Data([0xff, 0xff]) else {
        return
    }

    tag.readWithoutEncryption(
        serviceCodeList: serviceCodeList,
        blockList: blockList) { status1, status2, dataList, error in
            guard error == nil, status1 == 0, status2 == 0 else {
                return
            }

            // dataの読み込み (次のセクションに続く)
    }
}
```

まずは、`requestService` を呼び出して、そのタグに読み取ろうとしているデータ (サービス) があるかどうかを確認します。
サービスが存在しない場合は 0xFF, 0xFF が返ります。 (FeliCa の仕様に基づく)

サービスが存在した場合は、`readWithoutEncryption` を呼び出して、データの取得をします。 (今回読み取るデータが認証不要なサービスのため、`readWithoutEncryption` になります)

FeliCa の仕様で、ステータスフラグがともに 0 のときのみ、正しいデータが取得可能なため、念の為チェックしておくと良さそうです。

## Suica データのデコード

```swift
for data in dataList {
    let year = data[4] >> 1
    let month = UInt16(bytes: data[4...5]) >> 5 & 0b1111
    let day = data[5] & 0b11111
    print("利用日: \(year)/\(month)/\(day)") // 19/8/27

    let entrance = UInt16(bytes: data[6...7])
    let exit = UInt16(bytes: data[8...9])
    print("入場駅: \(entrance), 出場駅: \(exit)")

    let balance = UInt16(bytes: data[10...11].reversed())
    print("残高: ", balance)
}
```

下記のページを参考にバイナリデータから必要なデータを読み取ります。
https://www.wdic.org/w/RAIL/サイバネ規格%20(ICカード) 

(`year` が 2000 年基準なのが面白いです)

Swift でバイナリデータを数値型に変換するのは少し手間なので上記では以下のようなエクステンションを利用しています。

```swift
extension FixedWidthInteger {
    init(bytes: UInt8...) {
        self.init(bytes: bytes)
    }

    init<T: DataProtocol>(bytes: T) {
        let count = bytes.count - 1
        self = bytes.enumerated().reduce(into: 0) { (result, item) in
            result += Self(item.element) << (8 * (count - item.offset))
        }
    }
}
```

↓のような感じで使えるので便利です。

```swift
XCTAssertEqual(UInt16(bytes: 0x35, 0x0B), 13579)
XCTAssertEqual(Int(bytes: 0x07, 0x5B, 0xCD, 0x15), 123456789)
```

# 便利なライブラリを作りました

前述のように読み取りには結構実装が必要で、面倒です。(エラーハンドリングなどを含めるとより手間)
そこで、サクッと使えるライブラリにしてみました。

https://github.com/tattn/NFCReader

```swift

let reader = Reader<Suica>()

reader.read(didBecomeActive: { _ in
    print("読み込み開始")
}, didDetect: { reader, result in
    switch result {
    case .success(let suica):
        let balance = suica.boardingHistories.first?.balance ?? 0
        reader.setMessage(balance)
    case .failure(let error):
        reader.setMessage("読み込みに失敗しました")
    }
})
```

このような感じで `Reader` の型パラメータに読み取りたい NFC タグを指定するだけで `struct` にマッピングされたデータを取得できます。

Suica の他にも以下のようなタグを読み取れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91631/637bbbb2-6c1f-e4db-be8d-d785e0d4f795.png)

ぜひ使ってみてください。
NFC タグの追加プルリクエストなども大募集中です。

# まとめ・所感

CoreNFC を使って FeliCa (Suica) を読み込む方法と作ったライブラリを紹介しました。

Suica の残高領域は 2 bytes しか用意されてないので 入金できる上限を簡単には増やせなさそうだなという発見もありました。

FeliCa の仕様はソニーが日本語で 丁寧に書いているのでとてもわかりやすかったです。
一方 Apple のドキュメントには現時点では全然情報がなく、FeliCa の仕様を知っている人でないと読めない感じでした。

# 参考文献

- https://developer.apple.com/videos/play/wwdc2019/715
- https://developer.apple.com/documentation/corenfc
- https://www.sony.co.jp/Products/felica/business/tech-support/index.html
- https://www.wdic.org/w/RAIL/サイバネ規格%20(ICカード)
- http://www014.upp.so-net.ne.jp/SFCardFan/
- https://ja.osdn.net/projects/felicalib/wiki/FrontPage