🪪

【Swift】NFC学生証(FeliCa)をiPhoneで読み取る

に公開

はじめに

iPhoneには近年、NFC(Near Field Communication)タグの読み取り機能が標準搭載されており、アプリから直接アクセスできるようになっている。
本記事では、日本の大学で一般的に使用されているFeliCa形式の学生証から、学籍番号などの情報を読み取るiOSアプリをSwiftで実装する方法を解説する。

NFCについて

「NFC」は通信規格の総称であり、ISO/IEC 14443(Type A/B)やISO/IEC 18092(FeliCa)などの種類がある。FeliCaはソニーが開発した非接触ICカード技術で、日本ではSuica、社員証、学生証などに多く採用されている。

今回の対象:筆者の大学の学生証

筆者の所属する大学の学生証では、以下のように情報が記録されている。またShift_JISでデコードすることができた。

  • システムコード(FeliCaリーダーが対象タグをフィルタするための識別子):0xFE00
  • 学籍番号が記録されているサービスエリア:0x1A8Bのブロック0番

これらの情報に基づき、学籍番号を読み取るアプリを作成していく。

nfcpyなどを用いてdump(フルスキャン)できる。具体的な方法については様々なサイトで紹介されているため本記事では割愛する。

Xcodeで必要な準備

  • CapabilitiesNear Field Communication Tag Reading を有効化
  • Info.plistPrivacy - NFC Scan Usage Description を追加
  • Info.plistISO18092 system codes for NFC Tag Reader Session を追加し、Item0にFE00と入力

実装

ユーザーが「Scan」ボタンを押すとNFCセッションが開始され、学生証をかざすと学籍番号が表示される。UIは非常にシンプルで、SwiftUIを使用して構築する。

1. SwiftUIによるユーザーインタフェース

struct ContentView: View {
    @StateObject private var nfcManager = NFCManager()

    var body: some View {
        VStack(spacing: 20) {
            Text("NFC Reader")
                .font(.title)
                .padding()

            Button(action: {
                nfcManager.beginSession()
            }) {
                Text("Scan")
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }

            Text(nfcManager.message)
                .padding()
                .foregroundColor(.gray)
            
            Text(nfcManager.result.dropFirst(2).dropLast(2))
                .font(.title2)
                .padding()
        }
        .padding()
    }
}
  • @StateObject を用いて NFCManager のインスタンスを管理する。
  • 「Scan」ボタンで NFC セッションを開始。
  • 結果表示用の result は読み取った学籍番号を表示している(前後の2文字を除去する処理は、学籍番号とは直接関係しない値が記録されていたため)。

2. NFCManagerクラス(CoreNFCによるFeliCaアクセス)

クラス全体のコードを順に示していく。

import Foundation
import CoreNFC
class NFCManager: NSObject, ObservableObject, NFCTagReaderSessionDelegate {
    @Published var message: String = "NFCタグを読み取ってください"
    @Published var result: String = ""
    
    private var session: NFCTagReaderSession?
  • messageはUI上にNFCTagReaderSessionから返されるアラート等を表示するためのもの
  • resultには読み取ったデータを入れる
    func beginSession() {
        guard NFCTagReaderSession.readingAvailable else {
            self.message = "このデバイスではNFCが利用できません。"
            return
        }

        session = NFCTagReaderSession(pollingOption: .iso18092, delegate: self)
        session?.alertMessage = "学生証をiPhoneにかざしてください"
        session?.begin()
    }
  • .iso18092 を指定することでFeliCaタグを対象とする。
  • session?.alertMessageにStringを入れることでセッション開始時の案内メッセージ(スキャン時のモーダルに表示される)を設定できる。
    func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {
        DispatchQueue.main.async {
            self.message = "NFCセッションが開始されました。"
        }
    }

    func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
        DispatchQueue.main.async {
            self.message = "セッション終了: \(error.localizedDescription)"
            self.session = nil
        }
    }

    func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
        guard let tag = tags.first else {
            session.invalidate(errorMessage: "タグが見つかりませんでした。")
            return
        }

        session.connect(to: tag) { error in
            if let error = error {
                session.invalidate(errorMessage: "接続失敗: \(error.localizedDescription)")
                return
            }
            
            guard case let .feliCa(felicaTag) = tag else {
                session.invalidate(errorMessage: "FeliCaタグではありません。")
                return
            }

            let serviceCode = Data([0x8B, 0x1A]) // サービスコード1A8B(Little Endian)
            let blockList = [Data([0x80, 0x00])] // ブロック0(学籍番号)

            felicaTag.readWithoutEncryption(serviceCodeList: [serviceCode], blockList: blockList) { statusFlag1, statusFlag2, blockData, error in
                if let error = error {
                    session.invalidate(errorMessage: "読み取り失敗: \(error.localizedDescription)")
                    return
                }

                if statusFlag1 == 0x00, let data = blockData.first {
                    if let decodedString = String(data: data, encoding: .shiftJIS) {
                        DispatchQueue.main.async {
                            self.result = decodedString
                        }
                    } else {
                        DispatchQueue.main.async {
                            self.result = "デコードに失敗しました"
                        }
                    }

                    session.invalidate() // セッションを終了
                } else {
                    session.invalidate(errorMessage: "ステータス異常: \(statusFlag1), \(statusFlag2)")
                }
            }
        }
    }
}
  • サービスコード 0x1A8B は学籍番号を含む領域で、Little Endian(※1)で送るため [0x8B, 0x1A] としている。
  • ブロック0番に学籍番号が格納されており、Shift_JISでデコードする。
  • 読み取り成功後は、セッションを明示的に終了する必要がある。
  • 必須なメソッドはtagReaderSession(_:didInvalidateWithError:)tagReaderSession(_:didDetect:)

※1 Little Endian(リトルエンディアン)とは、データをバイト列に並べるときの順番のルール(バイトオーダー)の1つ。下位バイト(0x8B)を先に、上位バイト(0x1A)を後に記述する。

おわりに

本記事では、FeliCaベースの学生証を読み取り、学籍番号を表示するiOSアプリの実装方法を紹介した。学生証に限らず、FeliCa周りの実装方法全般を理解することができたので今後の開発に活かせそうだ。
また本記事で扱ったサンプルアプリのコードは以下に掲載している。
https://github.com/myml12/iOS-FeliCa-StudentID-Reader

Discussion