はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 10 日目の記事です。
10 日目は交通系 IC カード読み取り処理を作成します。
私が使っているのは ICOCCA なので Core NFC を使って ICOCCA を読み取れるようにします(他のカードも同様にできるはず)。
完成形はこんな感じです。
ほぼこちらの記事を参考にしました。
権限追加
Core NFC を使うために権限を追加していきます。
TARGETS > Signing&Capabilities > + Capability から「Near Field Communication Tag Reading」を追加します。
TARGETS > Info に「Privacy - NFC Scan Usage Description」を追加します。
TARGETS > Info に「ISO18092 system codes for NFC Tag Reader Session」を追加し Array の 1 つ目に「0003」を追加します。
よくわかりませんが交通系 IC カードのシステムコードは「0003」らしいです。
FeliCa データ構造などについては下記記事参考。
読み取り処理
FeliCaSession.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
import CoreNFC import Foundation enum FeliCaSessionError: Error { /// 利用不可 case invalid /// タグが見つからない case notFound /// タグと接続失敗 case notConnected /// サービス利用不可 case invalidService /// 履歴利用不可 case invalidHistories } final class FeliCaSession: NSObject, ObservableObject, @unchecked Sendable { private var session: NFCTagReaderSession! private var readHandler: (([Data]?, FeliCaSessionError?) -> Void)? /// 読み取りを開始する /// - Parameter readHandler: 完了ハンドラ /// /// [Data]?:日付降順で最大20件、エラーの場合はnil /// /// FeliCaSessionError:データを読み取れなかった場合のみ値を設定 func startReadSession(readHandler: (([Data]?, FeliCaSessionError?) -> Void)?) { self.readHandler = readHandler startSession() } private func startSession() { guard NFCNDEFReaderSession.readingAvailable else { readHandler?(nil, .invalid) return } session = NFCTagReaderSession(pollingOption: .iso18092, delegate: self) session.alertMessage = "スキャン中" session.begin() } private func handle(dataList: [Data]?, error: FeliCaSessionError?) { session.invalidate() DispatchQueue.main.async { self.readHandler?(dataList, error) } } private func readWithoutEncryption(feliCaTag: NFCFeliCaTag, serviceCode: Data, blockList: [Data], completion: @escaping ([Data]?, FeliCaSessionError?) -> Void) { feliCaTag.readWithoutEncryption(serviceCodeList: [serviceCode], blockList: blockList) { status1, status2, dataList, error in if let error = error { print(error) completion(nil, .invalidHistories) return } // ステータスがどちらも「0」のときにデータ取得可能 guard status1 == 0, status2 == 0 else { print("ステータスフラグエラー: ", status1, " / ", status2) completion(nil, .invalidHistories) return } completion(dataList, nil) } } } extension FeliCaSession: NFCTagReaderSessionDelegate { func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) { } func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) { } func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { guard let tag = tags.first, case let .feliCa(feliCaTag) = tag else { handle(dataList: nil, error: .notFound) return } session.connect(to: tag) { [weak self] error in if let error = error { print(error) self?.handle(dataList: nil, error: .notConnected) return } // 乗降履歴情報のサービスコードは「009F」リトルエンディアンなので反転 let historyServiceCode = Data([0x09, 0x0f].reversed()) feliCaTag.requestService(nodeCodeList: [historyServiceCode]) { nodes, error in if let error = error { print(error) self?.handle(dataList: nil, error: .invalidService) return } guard let data = nodes.first, // 「FFFF」であればサービスが存在している data != Data([0xff, 0xff]) else { print("サービスが存在しない") self?.handle(dataList: nil, error: .invalidService) return } // 履歴は20件まで読み取れるが一度に12件までしか取れないので2回にわける let blockList1 = (0..<10).map { Data([0x80, UInt8($0)]) } let blockList2 = (10..<20).map { Data([0x80, UInt8($0)]) } self?.readWithoutEncryption(feliCaTag: feliCaTag, serviceCode: historyServiceCode, blockList: blockList1, completion: { dataList1, error in if let error = error { self?.handle(dataList: nil, error: error) return } self?.readWithoutEncryption(feliCaTag: feliCaTag, serviceCode: historyServiceCode, blockList: blockList2, completion: { dataList2, error in if let error = error { self?.handle(dataList: nil, error: error) return } self?.handle(dataList: (dataList1 ?? []) + (dataList2 ?? []), error: nil) }) }) } } } } |
注意しなければいけないのは下記のコードコメントの部分です。
履歴は 20 件読み取れるらしいのですが 1 度に 12 件までしか読み取れないので取得処理を 2 回にわける必要があります。
1 2 3 |
// 履歴は20件まで読み取れるが一度に12件までしか取れないので2回にわける let blockList1 = (0..<10).map { Data([0x80, UInt8($0)]) } let blockList2 = (10..<20).map { Data([0x80, UInt8($0)]) } |
RegisterView の読み取りボタンの処理を下記のように修正すれば読み取りは完成です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// 追加 @StateObject private var feliCaSession = FeliCaSession() Button { feliCaSession.startReadSession { dataList, error in if let error = error { print(error) } else if let dataList = dataList { // ここに解析処理 } } } label: { HStack { Image(systemName: "creditcard.viewfinder") Text("交通系ICカードを読み取る") } } |
データ整形
交通系 IC カードから読み取った 16 byte のデータから下記内容を取得する必要があります。
- 乗車日
- 乗車駅
- 降車駅
- 金額
解析処理は下記を参考にしました。
FeliCaDataFormatter.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
import Foundation struct FeliCaDataFormatter { /// 交通系ICカードから読み取ったデータを家計簿データに整形する /// - Parameter feliCaData: 交通系ICカードから読み取ったデータ(日付降順で最大20件) /// - Returns: 整形した家計簿データ func format(feliCaData: [Data]) -> [Item] { return feliCaData.enumerated().compactMap { index, value in // 乗車日取得 let components = DateComponents( calendar: .init(identifier: .gregorian), year: Int(value[4] >> 1) + 2000, month: ((value[4] & 1) == 1 ? 8 : 0) + Int(value[5] >> 5), day: Int(value[5] & 0x1f) ) guard let date = components.date else { return nil } // 乗車駅・降車駅取得 let start = value[6...7].map { String(format: "%03d", $0) }.joined() let startValue = String(start.prefix(3)) + "-" + String(start.suffix(3)) let end = value[8...9].map { String(format: "%03d", $0) }.joined() let endValue = String(end.prefix(3)) + "-" + String(end.suffix(3)) let name = "\(startValue) - \(endValue)" // 金額取得 let price: Int if feliCaData.count > index + 1 { let nextData = feliCaData[index + 1] let current = Int(value[10]) + Int(value[11]) << 8 let next = Int(nextData[10]) + Int(nextData[11]) << 8 price = next - current } else { return nil } return Item( id: UUID().uuidString, date: date, name: name, price: price, categoryValue: Category.entertainmentValue, categorySubValue: EntertainmentSub.traffic.rawValue ) } } } |
これで家計簿データに整形できましたがこのままだと乗車駅・降車駅が「001-230」などどの駅かわかりません。
駅名の取得
読み取った「001-230」というのは駅コードを表しているらしく下記サイトを参考に該当する駅を探します。
すべての駅に対応させるのはしんどいので自分がよく利用する駅のみ対応するようにします。
FeliCaStation.swift というファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import Foundation enum FeliCaStation: String { /// JR京都 case jrKyoto = "001-204" /// JR新大阪 case jrShinOsaka = "001-230" /// JR大阪 case jrOsaka = "001-232" /// JR高槻 case jrTakatsuki = "001-216" /// 阪急梅田 case hankyuUmeda = "157-001" /// 阪急高槻市 case hankyuTakatsuki = "159-029" } extension FeliCaStation { var name: String { switch self { case .jrKyoto: return "JR京都駅" case .jrShinOsaka: return "JR新大阪駅" case .jrOsaka: return "JR大阪駅" case .jrTakatsuki: return "JR高槻駅" case .hankyuUmeda: return "阪急梅田駅" case .hankyuTakatsuki: return "阪急高槻市駅" } } } |
FeliCaDataFormatter の駅の解析処理を下記のように修正します。
1 |
let name = "\(FeliCaStation(rawValue: startValue)?.name ?? startValue) - \(FeliCaStation(rawValue: endValue)?.name ?? endValue)" |
これで駅名表示もできるようになりました。
データの追加
あとは RegisterView に下記のようにデータ追加処理を書けば交通系 IC カードの読み取り処理は完成です。
1 2 3 4 5 6 7 |
feliCaSession.startReadSession { dataList, error in if let error = error { print(error) } else if let dataList = dataList { items.append(contentsOf: FeliCaDataFormatter().format(feliCaData: dataList)) } } |
おわりに
ICOCCA しか持っていないので他の交通系 IC カードでは試していませんがおそらく同じように読み取れるはずです。
あまり NFC などに詳しくないのですが検索すればやり方がでてくるのでいい時代になりましたね。
私は Sendable についてあまり理解していないので Swift 6 に対応したことを少し後悔し始めましたが頑張って続けていきます!
明日はレシート読み取り機能を作成予定です。
コメント