0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

.NETアプリからFeliCaコマンドではなくPC/SC APDUコマンドで使って交通系ICカードの利用履歴を読み取る

Last updated at Posted at 2026-01-20

:star: はじめに

以前の記事でPC/SC APDUコマンドを使ったマイナンバーカードの情報を読み取りについて書きました。
その派生として、交通系ICカードの利用履歴をFeliCaコマンドではなくPC/SC APDUコマンドを使って取得する方法も書いておきます。

:pencil: 概要

実行環境

項目 概要
OS Windows 11及びUbuntu 24.04
リーダー PaSoRi RC-S300
言語/ランタイム C#/.NET 10
ライブラリ PCSC-sharp

前提知識

  • PC/SC、APDUの話についてはマイナンバーカードの読み取りについての記事や他サイトを参照してください
  • FeliCaコマンドを使って利用履歴を参照する場合、Read Without Encryptionコマンドでサービスコードを指定して読み取りますが、今回の記事はFeliCaコマンドの代わりにAPDUコマンドでベンダ独自の命令コードを使って同様のことを行なうものです
  • 読み取り箇所の指定や取得できるデータの構造はRead Without Encryptionを使った場合と同様なので、その構造などについては他サイトを参照してください(利用履歴のサービスコード:0x090F等)

:hammer_pick: プログラム

ソース

今回の記事固有部分についてのみ記述します。
SendCommand()、CreateCommand()メソッド、Responseクラス等の処理については以前の記事のものに同じなのでそちらを参照してください。

public static void Main()
{
    // 前の記事はPaSoRiにカードを置いたことをトリガーにしていたが、今回のサンプルはカードが置かれている状態での動作
    using var context = ContextFactory.Instance.Establish(SCardScope.System);
    var readers = context.GetReaders();
    if (readers.Length > 0)
    {
        var reader = context.ConnectReader(readers[0], SCardShareMode.Shared, SCardProtocol.Any);
        try
        {
            Execute(reader);
        }
        catch (Exception e)
        {
            Console.WriteLine(e);
        }
        finally
        {
            reader.Disconnect(SCardReaderDisposition.Leave);
        }
    }
}

private static void Execute(ICardReader reader)
{
    // FeliCaコマンドではなくAPDUコマンドで領域を読み取るサンプル

    // IDm取得 FF:ベンダ独自 CA:ベンダ固有GET DATA
    var response = SendCommand(reader, CreateCommand(0xFF, 0xCA, 0x00, 0x00, 0x00));
    if (!response.IsSuccess())
    {
        Console.WriteLine($"IDm取得失敗: SW={response.SW1:X2}{response.SW2:X2}");
        return;
    }

    var idm = Convert.ToHexString(response.Data);
    Console.WriteLine($"IDm: {idm}");

    // 基本情報選択  FF:ベンダ独自 A4:ベンダ固有SELECT 0x008B
    response = SendCommand(reader, CreateCommand(0xFF, 0xA4, 0x00, 0x01, [0x8B, 0x00]));
    if (!response.IsSuccess())
    {
        Console.WriteLine($"基本情報選択失敗: SW={response.SW1:X2}{response.SW2:X2}");
        return;
    }

    // 基本情報読取  FF:ベンダ独自 B0:ベンダ固有READ
    response = SendCommand(reader, CreateCommand(0xFF, 0xB0, 0x00, 0x00, 0x00));
    if (!response.IsSuccess())
    {
        Console.WriteLine($"基本情報読取失敗: SW={response.SW1:X2}{response.SW2:X2}");
        return;
    }

    Console.WriteLine($"残高: \\{SuicaLogic.ExtractAccessBalance(response.Data)}");

    // 利用履歴選択 FF:ベンダ独自 A4:ベンダ固有SELECT 0x090F
    response = SendCommand(reader, CreateCommand(0xFF, 0xA4, 0x00, 0x01, [0x0F, 0x09]));
    if (!response.IsSuccess())
    {
        Console.WriteLine($"利用履歴選択失敗: SW={response.SW1:X2}{response.SW2:X2}");
        return;
    }

    for (var i = 0; i < 20; i++)
    {
        // 利用履歴読取  FF:ベンダ独自 B0:ベンダ固有READ
        response = SendCommand(reader, CreateCommand(0xFF, 0xB0, 0x00, (byte)i, 0x00));
        if (!response.IsSuccess())
        {
            Console.WriteLine($"利用履歴読取失敗: SW={response.SW1:X2}{response.SW2:X2}");
            return;
        }

        Console.WriteLine($"{SuicaLogic.ExtractLogDateTime(response.Data):yyyy/MM/dd HH:mm:ss}  " +
                          $"{SuicaLogic.ConvertTerminalString(SuicaLogic.ExtractLogTerminal(response.Data))}-" +
                          $"{SuicaLogic.ConvertProcessString(SuicaLogic.ExtractLogProcess(response.Data))}  " +
                          $" \\{SuicaLogic.ExtractLogBalance(response.Data)}");
    }
}
// 受信したデータの解析用ヘルパー
public static class SuicaLogic
{
    private static readonly Dictionary<byte, string> TerminalNames = new()
    {
        { 3, "精算機" },
        { 4, "携帯型端末" },
        { 5, "車載端末" },
        { 7, "券売機" },
        { 8, "券売機" },
        { 9, "入金機" },
        { 18, "券売機" },
        { 20, "券売機等" },
        { 21, "券売機等" },
        { 22, "改札機" },
        { 23, "簡易改札機" },
        { 24, "窓口端末" },
        { 25, "窓口端末" },
        { 26, "改札端末" },
        { 27, "携帯電話" },
        { 28, "乗継精算機" },
        { 29, "連絡改札機" },
        { 31, "簡易入金機" },
        { 199, "物販端末" },
        { 200, "自販機" }
    };

    private static readonly Dictionary<byte, string> ProcessNames = new()
    {
        { 1, "運賃支払" },
        { 2, "チャージ" },
        { 3, "磁気券購入" },
        { 4, "精算" },
        { 5, "入場精算" },
        { 6, "改札窓口処理" },
        { 7, "新規発行" },
        { 8, "窓口控除" },
        { 13, "バス(PiTaPa系)" },
        { 15, "バス(IruCa系)" },
        { 17, "再発行処理" },
        { 19, "新幹線利用" },
        { 20, "入場時AC" },
        { 21, "出場時AC" },
        { 31, "バスチャージ" },
        { 35, "バス路面電車企画券購入" },
        { 70, "物販" },
        { 72, "特典チャージ" },
        { 73, "レジ入金" },
        { 74, "物販取消" },
        { 75, "入場物販" }
    };

    private static readonly HashSet<byte> ProcessOfSales = [70, 72, 73, 74, 75];

    private static readonly HashSet<byte> ProcessOfBus = [13, 15, 31, 35];

    private static readonly Dictionary<int, string> RegionNames = new()
    {
        { 0, "首都圏" },
        { 1, "中部圏" },
        { 2, "近畿圏" },
        { 3, "その他" }
    };

    public static string ConvertTerminalString(byte type) =>
        TerminalNames.TryGetValue(type, out var value) ? value : type.ToString("X");

    public static string ConvertProcessString(byte process)
    {
        var processType = ConvertProcessType(process);
        var withCache = (process & 0b10000000) != 0;

        var name = ProcessNames.TryGetValue(processType, out var value) ? value : processType.ToString("X");

        return withCache ? name + " 現金併用" : name;
    }

    public static byte ConvertProcessType(byte process) =>
        (byte)(process & 0b01111111);

    public static bool IsProcessOfSales(byte process)
    {
        var processType = ConvertProcessType(process);
        return ProcessOfSales.Contains(processType);
    }

    public static bool IsProcessOfBus(byte process)
    {
        var processType = ConvertProcessType(process);
        return ProcessOfBus.Contains(processType);
    }

    public static string ConvertRegionString(int region) =>
        RegionNames.TryGetValue(region, out var value) ? value : region.ToString("X");

    private static DateTime ExtractDate(ReadOnlySpan<byte> bytes)
    {
        var year = 2000 + (bytes[0] >> 1);
        var month = BinaryPrimitives.ReadUInt16BigEndian(bytes[..2]) >> 5 & 0b1111;
        var day = bytes[1] & 0b11111;
        return new DateTime(year, month, day);
    }

    private static DateTime ExtractDateTime(ReadOnlySpan<byte> bytes)
    {
        var year = 2000 + (bytes[0] >> 1);
        var month = BinaryPrimitives.ReadUInt16BigEndian(bytes[..2]) >> 5 & 0b1111;
        var day = bytes[1] & 0b11111;
        var hour = bytes[2] >> 3;
        var minute = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(2, 2)) >> 5 & 0b111111;
        return new DateTime(year, month, day, hour, minute, 0);
    }

    public static int ExtractAccessBalance(ReadOnlySpan<byte> bytes) =>
        BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(11, 2));

    public static int ExtractAccessTransactionId(ReadOnlySpan<byte> bytes) =>
        BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(14, 2));

    public static bool IsValidLog(ReadOnlySpan<byte> bytes) =>
        bytes[1] != 0x00;

    public static byte ExtractLogTerminal(ReadOnlySpan<byte> bytes) =>
        bytes[0];

    public static byte ExtractLogProcess(ReadOnlySpan<byte> bytes) =>
        bytes[1];

    public static DateTime ExtractLogDateTime(ReadOnlySpan<byte> bytes) =>
        IsProcessOfSales(ExtractLogProcess(bytes)) ? ExtractDateTime(bytes[4..]) : ExtractDate(bytes[4..]);

    public static int ExtractLogBalance(ReadOnlySpan<byte> bytes) =>
        BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(10, 2));

    public static int ExtractLogTransactionId(ReadOnlySpan<byte> bytes) =>
        BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(13, 2));
}

実行例

IDmはマスク。

[送信] FF-CA-00-00-00
[受信] XX-XX-XX-XX-XX-XX-XX-XX-90-00
IDm: XXXXXXXXXXXXXXXX
[送信] FF-A4-00-01-02-8B-00
[受信] 90-00
[送信] FF-B0-00-00-00
[受信] 01-02-03-04-05-06-07-08-30-00-00-FF-0A-00-15-76-90-00
残高: \2815
[送信] FF-A4-00-01-02-0F-09
[受信] 90-00
[送信] FF-B0-00-00-00
[受信] C7-46-00-00-32-7A-72-00-00-0F-FF-0A-00-15-76-00-90-00
2025/03/26 14:16:00  物販端末-物販   \2815
[送信] FF-B0-00-01-00
[受信] C7-46-00-00-32-7A-61-E0-00-0F-00-0B-00-15-75-00-90-00
2025/03/26 12:15:00  物販端末-物販   \2816
[送信] FF-B0-00-02-00
[受信] C7-46-00-00-32-7A-60-C0-00-0F-01-0B-00-15-74-00-90-00
2025/03/26 12:06:00  物販端末-物販   \2817
[送信] FF-B0-00-03-00
[受信] C7-46-00-00-32-7A-60-20-00-0F-02-0B-00-15-73-00-90-00
2025/03/26 12:01:00  物販端末-物販   \2818
[送信] FF-B0-00-04-00
[受信] C7-46-00-00-32-7A-60-20-00-0F-03-0B-00-15-72-00-90-00
2025/03/26 12:01:00  物販端末-物販   \2819
[送信] FF-B0-00-05-00
...

ソース解説

  • ICardReaderを使用した処理の流れはマイナンバーの記事に同じなのでそちらを参照してください
  • FeliCaコマンドではRead Without Encryptionで処理するところを、このサンプルではベンダ独自のAPDUコマンドで処理しています
  • まず、FeliCaコマンドで使用するサービスコードをパラメータにしてベンダ独自コマンドで読み取り箇所の選択[FF A4]を行い、続けてベンダ独自コマンドで読み取り[FF B0]を行っています
  • これを基本情報の領域(サービスコード:0x008B)と利用履歴の領域(サービスコード:0x090F)に対して行い、その結果を出力しています
  • 利用履歴については最大20件書き込まれるので、P2でブロック番号を指定して件数分の読み取りを行っています

:rabbit: うさコメ

マイナンバー読み取りの記事のついでに、交通系ICカードの非暗号化領域をPC/SCで読み取る記事も書いてみました。

AndroidのNfcFクラスのようにFeliCaコマンドを使用するAPIの場合は、FeliCaコマンドのRead Without Encryptionを使って読み取ります。
例えばMAUI AndroidでFeliCaコマンドを使って利用履歴を読み取るサンプルは以下の中にもあります。

Device_NFC.png

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?