前回の記事は 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)
実行結果
ライブラリを使う場合
前々回の記事で紹介した treastrain/TRETJapanNFCReader では 楽天Edy、nanaco、WAON などの電子マネーの読み取りも行うことができます。
Info.plist と Capability と Entitlements の設定を済ませ、ライブラリを導入してコードを数行書くけば簡単に残高や利用履歴を取得できます。
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」を追加します。
すると、「<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
ファイルツリー
今回のプロジェクトのファイルツリーを確認しておきます。
処理は全て 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
なら正常に終了しています。
もし、status1
が 1
、status2
が 166
(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 コマンドが終了したら、いよいよデータの解析です。
解析をしやすいように NSData
で print
しました。
…と、言っても今回の 楽天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 にも載せておきました。参考にしてください。