7
9

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.

Core NFCAdvent Calendar 2019

Day 11

iPhone で電子マネー(楽天Edy、nanaco、WAON)を読み取ってみよう!

Last updated at Posted at 2019-12-10

前回の記事は iPhone で運転免許証を読み取ってみよう!【ライブラリを使わずに350行の Swift で本籍を読み取る】 で、前々回とは異なりライブラリなしで運転免許証を読み取る方法を紹介しました。

しかし、運転免許証は個人情報の塊、かつ暗証番号は絶対に間違えてはいけないので気軽に試せない…。
今回は電子マネー(楽天Edy、nanaco、WAON)を読み取っていきます。運転免許証に比べては簡単に、お手軽に試せますね!

環境

  • 開発
    • Xcode Version 11.2.1 (11B500)
    • Apple Swift version 5.1.2 (swiftlang-1100.0.278 clang-1100.0.33.9)
    • macOS Catalina 10.15.1(19B88)
  • 実機
    • iPhone 11 Pro (A2215、MWCC2J/A)
    • iOS 13.2.3 (17B111)

実行結果

スクリーンショット 2019-12-11 1.30.11.png

ライブラリを使う場合

前々回の記事で紹介した treastrain/TRETJapanNFCReader では 楽天Edy、nanaco、WAON などの電子マネーの読み取りも行うことができます。
Info.plist と Capability と Entitlements の設定を済ませ、ライブラリを導入してコードを数行書くけば簡単に残高や利用履歴を取得できます。

treastrain/TRETJapanNFCReaderを使った場合
import UIKit
import TRETJapanNFCReader
class ViewController: UIViewController, FeliCaReaderSessionDelegate {

    var reader: RakutenEdyReader!

    override func viewDidLoad() {
        super.viewDidLoad()
        self.reader = RakutenEdyReader(viewController: self)
        self.reader.get(itemTypes: [.balance])
    }

    func feliCaReaderSession(didRead feliCaCard: FeliCaCard) {
        let rakutenEdyCard = feliCaCard as! RakutenEdyCard
        let balance = rakutenEdyCard.data.balance! // カード残高
    }
}

treastrain/TRETJapanNFCReader/README.md より

しかし、せっかくの Core NFC Advent Calendar なので、以降はライブラリなし、直接 Core NFC を操作して電子マネーの残高を読み取っていきます。

Xcode プロジェクトの作成

まずは Create a new Xcode project から iOS の Single View App を作成します。

Capability と Entitlements の設定

Project の Target から Signing & Capabilities を選択し、「+ Capability」から「Near Field Communication Tag Reading」を追加します。
スクリーンショット 2019-12-11 0.41.35.png
すると、「<Product-Name>.entitlements」というファイルが追加されているので、「Near Field Communication Tag Reader Session Formats」に「NFC Data Exchange Format」および「NFC tag-specific data protocol」があることを確認します。

Info.plist の設定

Info.plist に「Privacy - NFC Scan Usage Description」と「ISO18092 system codes for NFC Tag Reader Session」を追加します。
「Privacy - NFC Scan Usage Description」には NFC を何のために使用するのかについての説明を、「ISO18092 system codes for NFC Tag Reader Session」の配下には以下の FeliCa システムコードを記述します。

  • FE00
スクリーンショット 2019-12-11 0.45.21.png 以上で設定はおしまいです。

ファイルツリー

今回のプロジェクトのファイルツリーを確認しておきます。
スクリーンショット 2019-12-11 0.48.34.png
処理は全て ViewController.swift に記述します。

コーディング

まずコード全文を載せた後、各部を解説していきます。運転免許証のときに比べればネストの深さはかわいいものです。

ViewController.swift
import UIKit
import CoreNFC

class ViewController: UIViewController, NFCTagReaderSessionDelegate {
    
    var session: NFCTagReaderSession?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        guard NFCTagReaderSession.readingAvailable else {
            print("NFC タグの読み取りに非対応のデバイス")
            return
        }
        
        self.session = NFCTagReaderSession(pollingOption: .iso18092, delegate: self)
        self.session?.alertMessage = "カードの上に iPhone の上部を載せてください"
        self.session?.begin()
    }
    
    func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {
        print("tagReaderSessionDidBecomeActive(_:)")
    }
    
    func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
        let readerError = error as! NFCReaderError
        print(readerError.code, readerError.localizedDescription)
    }
    
    func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
        print("tagReaderSession(_:didDetect:)")
        
        let tag = tags.first!
        session.connect(to: tag) { (error) in
            if let error = error {
                session.invalidate(errorMessage: error.localizedDescription)
                return
            }
            
            guard case NFCTag.feliCa(let feliCaTag) = tag else {
                session.invalidate(errorMessage: "FeliCa ではない")
                return
            }
            
            session.alertMessage = "カードを読み取っています…"
            
            let idm = feliCaTag.currentIDm.map { String(format: "%.2hhx", $0) }.joined()
            print("IDm:", idm)
            
            /// FeliCa サービスコード
            let rakutenEdyServiceCode = Data([0x13, 0x17].reversed())
            let nanacoServiceCode = Data([0x55, 0x97].reversed())
            let waonServiceCode = Data([0x68, 0x17].reversed())
            let serviceCodeList = [rakutenEdyServiceCode]
            
            /// ブロック数
            let blocks = 1
            let blockList = (0..<blocks).map { (block) -> Data in
                Data([0x80, UInt8(block)])
            }
            
            feliCaTag.readWithoutEncryption(serviceCodeList: serviceCodeList, blockList: blockList) { (status1, status2, blockData, error) in
                if let error = error {
                    session.invalidate(errorMessage: error.localizedDescription)
                    return
                }
                
                guard status1 == 0x00, status2 == 0x00 else {
                    print("ステータスフラグがエラーを示しています", status1, status2)
                    session.invalidate(errorMessage: "ステータスフラグがエラーを示しています s1:\(status1), s2:\(status2)")
                    return
                }
                
                let data = blockData.first!
                let balance = data.toIntReversed(0, 3)
                
                print(data as NSData)
                print("残高: ¥\(balance)")
                session.alertMessage = "残高: ¥\(balance)"
                session.invalidate()
            }
        }
    }
}

extension Data {
    /// https://github.com/treastrain/TRETJapanNFCReader/blob/master/TRETJapanNFCReader/Extensions.swift#L112
    func toIntReversed(_ startIndex: Int, _ endIndex: Int) -> Int {
        var s = 0
        for (n, i) in (startIndex...endIndex).enumerated() {
            s += Int(self[i]) << (n * 8)
        }
        return s
    }
}

コード解説

対応端末かどうかを確かめる

guard NFCTagReaderSession.readingAvailable else {
    print("NFC タグの読み取りに非対応のデバイス")
    return
}

前回に引き続き Core NFC を使うときのお約束、そもそも使っている端末が読み取りに対応しているかどうかを NFCTagReaderSession.readingAvailable で確かめます。

NFCTagReaderSession をスタートさせる

self.session = NFCTagReaderSession(pollingOption: .iso18092, delegate: self)
self.session?.alertMessage = "カードの上に iPhone の上部を載せてください"
self.session?.begin()

対応端末であることを確認した後、NFCTagReaderSession を初期化して begin() でスタートさせます。読み取るカードは FeliCa、つまり ISO 18092 なので、pollingOption もそのように指定します。

NFCTagReaderSessionDelegate

func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {
    print("tagReaderSessionDidBecomeActive(_:)")
}

func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
    let readerError = error as! NFCReaderError
    print(readerError.code, readerError.localizedDescription)
}

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

この部分の説明は前回の記事と全く同じなので省略します。

タグに接続する

let tag = tags.first!
session.connect(to: tag) { (error) in
    if let error = error {
        session.invalidate(errorMessage: error.localizedDescription)
        return
    }
    
    guard case NFCTag.feliCa(let feliCaTag) = tag else {
        session.invalidate(errorMessage: "FeliCa ではない")
        return
    }
    
    session.alertMessage = "カードを読み取っています…"
    
    let idm = feliCaTag.currentIDm.map { String(format: "%.2hhx", $0) }.joined()
    print("IDm:", idm)
    
    // …
}

まずは session.connect(to:completionHandler:) でタグに接続します。
接続したタグが FeliCa であるかを確かめ、そうでない場合は session.invalidate(errorMessage:) でセッションを終了します。
ついでに FeliCa タグに割り振られている IDm くらいは print しておきましょうか。
今回は FeliCa システムコードを FE00 のみ Info.plist に記述していますので、feliCaTag.currentIDm は必ず FE00 の IDm になります。

FeliCa サービスコードの指定

/// FeliCa サービスコード
let rakutenEdyServiceCode = Data([0x13, 0x17].reversed())
let nanacoServiceCode = Data([0x55, 0x97].reversed())
let waonServiceCode = Data([0x68, 0x17].reversed())
let serviceCodeList = [rakutenEdyServiceCode]

/// ブロック数
let blocks = 1
let blockList = (0..<blocks).map { (block) -> Data in
    Data([0x80, UInt8(block)])
}

さて、FeliCa ですがデータ構造を少し理解しておく必要があります。
公式の情報であれば FeliCaカード ユーザーズマニュアル 抜粋版、Qiita の記事であれば [PASMO] FeliCa から情報を吸い出してみる - FeliCaの仕様編 [Android][Kotlin] がとてもわかりやすいと思います。

やはり Android の情報にたどりつく…

次の Read Without Encryption コマンドを送信するために、サービスコードをリトルエンディアンで準備しておきます。

Read Without Encryption コマンドを送信

feliCaTag.readWithoutEncryption(serviceCodeList: serviceCodeList, blockList: blockList) { (status1, status2, blockData, error) in
    if let error = error {
        session.invalidate(errorMessage: error.localizedDescription)
        return
    }
    
    guard status1 == 0x00, status2 == 0x00 else {
        print("ステータスフラグがエラーを示しています", status1, status2)
        session.invalidate(errorMessage: "ステータスフラグがエラーを示しています s1:\(status1), s2:\(status2)")
        return
    }
    
    // …
}

Read Without Encryption コマンドを FeliCa タグに送信します。FeliCa のステータスフラグについては FeliCaカード ユーザーズマニュアル 抜粋版 に記載があります。両方とも 0 なら正常に終了しています。
もし、status11status2166 (0xA6) であれば "サービスコードリスト不正" にあたるので、前項のサービスコードの指定が間違っていることになります。serviceCodeList の中身が読み取ろうとしたカードに合っていますか?

データの解析

let data = blockData.first!
let balance = data.toIntReversed(0, 3)

print(data as NSData)
print("残高: ¥\(balance)")
session.alertMessage = "残高: ¥\(balance)"
session.invalidate()

エラーなく Read Without Encryption コマンドが終了したら、いよいよデータの解析です。
解析をしやすいように NSDataprint しました。

…と、言っても今回の 楽天Edy、nanaco、WAON の残高は先頭4ビットがリトルエンディアンで入っているので、それを Int にします。toIntReversed は以下の extension に定義しました。

extension Data {
    /// https://github.com/treastrain/TRETJapanNFCReader/blob/master/TRETJapanNFCReader/Extensions.swift#L112
    func toIntReversed(_ startIndex: Int, _ endIndex: Int) -> Int {
        var s = 0
        for (n, i) in (startIndex...endIndex).enumerated() {
            s += Int(self[i]) << (n * 8)
        }
        return s
    }
}

すべて終わったら忘れずに session.invalidate() を呼んでセッションを正しく終了させましょう。

後記

お疲れさまでした。前回のコード量の3分の1です。
とても簡単に残高を取得できました。
Core NFC、どんどん使っていきましょう…!

ここでのコードは Gist にも載せておきました。参考にしてください。

7
9
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
7
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?