Python2 + nfcpyで、「長野市バス共通ICカード KURURU(くるる)」の履歴を読んでみた

以前、Android + RubotoにてFeliCaのKURURUの履歴を読んでみました。
Rubotoを使い、Androidで「長野市バス共通ICカード KURURU(くるる)」の履歴を読んでみた - メモ的な思考的な

そこで今回は Python2 + nfcpy でKURURUを読んでみます。

 
とはいえ、nfcpyでFeliCaを読む方法がよくわかりませんでした。

調べてみたところ、以下がとても参考になりました。ありがとうございます。

 
そこで、前者のリポジトリに含まれる suica_read.py (Suicaを読むnfcpyコード) を理解しながら、KURURU用のコードを書いてみました。

なお、このレイヤを扱ったことがないため、考えたことを中心に書きます*1。もし誤りなどがありましたら、ご指摘ください。

 
目次

 

環境

 

suica_read.pyを理解する

suica_read.pyソースコードを見たところ、いくつか分からないところがあったため、順に書いていきます。

なお、理解するために使った履歴データは、以前の履歴No.1のものを使いました。

f:id:thinkAmi:20131025055753p:plain

 

struct.unpack('>2B2H4BH4B', data)

まずは

# ビッグエンディアンでバイト列を解釈する
row_be = struct.unpack('>2B2H4BH4B', data)
# リトルエンディアンでバイト列を解釈する
row_le = struct.unpack('<2B2H4BH4B', data)

についてです。

 
参考にしたリポジトリでは、標準モジュールの struct.unpack() モジュールを使って、Suicaデータを解析しています。
7.3. struct — 文字列データをパックされたバイナリデータとして解釈する — Python 2.7.14 ドキュメント

今回分からなかったのは、 >2B2H4BH4B の部分です。

Pythonドキュメントを読むと、

というフォーマット文字でした。
7.3. struct — 文字列データをパックされたバイナリデータとして解釈する — Python 2.7.14 ドキュメント

ただ、 2B2H4BH4B が何を表しているのかよく分かりませんでした。

さらに調べてみたところ、以下の記事がありました。
python - struct.error: unpack requires a string argument of length 4 - Stack Overflow

struct.calcsize()を使えば何か分かるかなと思い、Suicaを置いて試してみたところ、

f = struct.calcsize('=2B2H4BH4B')
print f
# => 16

と表示されました。

 
16byteを表していることがわかったため、PythonのドキュメントとSuicaのフォーマットと比べてみました。

バイト Pythonフォーマット Suicaフォーマット
0 B 端末種
1 B 処理
2-3 H ??
4-5 H 日付 (先頭から7ビットが年、4ビットが月、残り5ビットが日)
6 B 入線区
7 B 入駅順
8 B 出線区
9 B 出駅順
10-11 H 残高 (little endian)
12-14 3B 連番
15 B リージョン

2B2H4BH4BSuicaフォーマットに一致していました。

 
では、KURURUの場合を調べてみます。

KURURUの履歴フォーマットは、Rubotoの時と同じく以下を参考にしました。ありがとうございます。
KURURU 履歴フォーマット | あたがわの日記

そのため、KURURUフォーマットをPythonフォーマットに当てはめてみます。

バイト Pythonフォーマット KURURUフォーマット
0-1 H 年月日
2 B 降車時刻
3-4 H 機番
5 B 降車時刻
6-7 H 乗車停留所
8-9 H 降車停留所
10 B 場所、種別
11 B 会社、割引
12-15 I 残高

となりました。

残高が4byte使っていたため、

  • BやHと同じ、unsignedなCの型
  • 標準の長さが4

Pythonフォーマットは unsigned int だったため、 12-15byteのところは I としました。

残高フォーマットは unsigned int なのかを試してみたところ、

print self.row_be[8]
#=> 1500

と表示されました。残高が取れているようです。

 

(date >> 9) & 0x7f

次は年月日の「年」を求めているところです。

date = row_be[3]
(date >> 9) & 0x7f

まず、 >> はシフト演算です。
6.8. シフト演算 (shifting operation) | 6. 式 (expression) — Python 3.6.3 ドキュメント

dateの値、および2進数に直した値、および右に9bitシフトした値を見たところ、以下でした。

print date
# => 6988
print bin(date)
# => 0b1101101001100
print date >> 9
# => 13
print bin(date >> 9)
#=> 0b1101

 
次に、 0x7fPythonの16進数表記なので、2進数に直すと 1111111 です。

& はANDなので、シフト演算の結果と 0x7f とのANDを取ると、

0001101
1111111
----------------------
0001101

2進数の 0001101 は13なので、これに2000を加えれば年になりました。

 
同じように月を求めると、

print date >> 5
#=> 218
print bin(date >> 5)
#=> 0b11011010

から 0x0f とのANDにて

11011010
00001111
----------------------
00001010

1010 となりました。10進数に直すと10です。

 
日についても同様ですが、 (date >> 0) はシフト演算をしないため実質 date & 0x1f となります。

print date >> 0
#=> 6988
print bin(date >> 0)
#=> 0b1101101001100

から 0x1f とのANDにて

1101101001100
0000000011111
----------------------
0000000000001100

1010 となりました。10進数に直すと12です。

合わせると、2013年10月12日となり、正しい年月日が取得できました。

 

ServiceCode(service_code >> 6 ,service_code & 0x3f)

続いてはServiceCodeオブジェクトを生成しているところです。

service_code = 0x090f
nfc.tag.tt3.ServiceCode(service_code >> 6 ,service_code & 0x3f)

 
ServiceCodeのコンストラクタでは、

  • 第一引数は サービスナンバー
  • 第二引数は サービス属性

です。

Suicaのサービスコード 0x090f (2byte) なため、それぞれ当てはめてみると

  • サービスナンバーは、上位10byte
    • 6byte右へシフト
  • サービス属性は、下位6byte
    • 0x3f111111 なので、下位6byteを捨てる

となりました。

 
KURURUのサービスコードは 0x000f なので、同じようにしてServiceCodeオブジェクトを生成すれば良さそうです。

 

KURURUを読む時に考えたこと

続いて、KURURUを読む時に考えたことをメモします。

 

時刻の算出について

Suicaとは異なり、KURURUには乗車時刻・降車時刻があります。

時刻については、

時刻は 0 時 0 分からの経過分を 10 で割った値が入っています.つまり,そこを 10 倍した値が 0 時 0 分からの経過分です.

KURURU 履歴フォーマット | あたがわの日記

とのことなので、編集が必要です。

 
self.row_be[3] で取得する値は16進数表記のint型です。

そのため、これを16進数表記intから、10進数表記intに変換します。

今回はstr文字列に一度戻してからもう一度intにします。

# 16進のintを16進表現の文字列にする
hex_time = hex(self.row_be[3])
print hex_time
#=> 0x71

# 16進表現の文字列を10進数値にする
int_time = int(hex_time, 16)
print int_time
#=> 113

得られた値は経過分の1/10なので10倍します。また、時間表現にするため、60で割ります。

これで商が時間、余りが分になります。

なお、Pythonでは商と余りを一度に求めるのに、divmod()関数が使えます。
divmod(a, b) | 2. 組み込み関数 — Python 2.7.14 ドキュメント

# 10倍
origin_time = int_time * 10

# 商と余りを求める
hm = divmod(origin_time, 60)

 
divmod()関数は、 (商, 余り) のタプルで値を戻すので、それを以下のフォーマットで時間表現にします。

'{hm[0]:02d}:{hm[1]:02d}:00'.format(hm=hm)

format()では、タプルは要素でのアクセス hm[0] できます。
7.1.3.2. 書式指定例 | 7.1. string — 一般的な文字列操作 — Python 2.7.14 ドキュメント

また、02d は、

  • 0 : マイナス符号なし
  • 2 : 幅として、2桁
  • d : 10進の数値

となります。
7.1.3.1. 書式指定ミニ言語仕様 | 7.1. string — 一般的な文字列操作 — Python 2.7.14 ドキュメント

 

ヒアドキュメント時のインデント削除

今回、ヒアドキュメントを使って履歴を表示します。

ただ、ヒアドキュメントを使うとインデントも表示されてしまうのが問題です。

インデントを削除する方法を調べたところ、標準モジュールの textwrap を使えば良さそうでした。

 

ソースコード全体

以上を踏まえたソースコードです。

コメントは省略しましたが、後述のGitHubのコードには記載してあります。

kururu_reader.py

# -*- coding: utf-8 -*-
# 以下を参考にKURURUを読みました。m2wasabiさん、ありがとうございます。
# https://github.com/m2wasabi/nfcpy-suica-sample/blob/master/suica_read.py
import struct
import textwrap

import nfc
import nfc.tag.tt3

KURURU_SERVICE_CODE = 0x000f


class HistoryRecord(object):
    def __init__(self, data):
        self.row_be = struct.unpack('>HBHBHHBBI', data)

    def is_empty(self):
        # 年月日がオールゼロの場合、履歴が無い空のレコードとみなす
        return not all([
            self.fetch_year(),
            self.fetch_month(),
            self.fetch_day(),
        ])

    def fetch_year(self):
        return (self.row_be[0] >> 9) & 0b1111111

    def fetch_month(self):
        return (self.row_be[0] >> 5) & 0b1111

    def fetch_day(self):
        return self.row_be[0] & 0b11111

    def fetch_alighting_time(self):
        return self.format_time(self.row_be[1])

    def fetch_machine_no(self):
        return self.row_be[2]

    def fetch_boarding_time(self):
        return self.format_time(self.row_be[3])

    def fetch_boarding_stop(self):
        return self.row_be[4]

    def fetch_alighting_stop(self):
        return self.row_be[5]

    def fetch_place(self):
        result = {
            0x05: '車内 ({})',
            0x07: '営業所 ({})',
            0x0E: '券売機 ({})',
        }.get(place, '不明 ({})')
        return result.format(hex(place))

    def fetch_category(self):
        category = self.row_be[6] & 0b1111
        result = {
            0x00: '入金 ({})',
            0x02: '支払 ({})',
        }.get(category, '不明 ({})')
        return result.format(hex(category))

    def fetch_company(self):
        company = (self.row_be[7] >> 4) & 0b1111
        result = {
            0x00: '長電バス ({})',
            0x03: 'アルピコバス ({})',
        }.get(company, '不明 ({})')
        return result.format(hex(company))

    def fetch_discount(self):
        discount = self.row_be[7] & 0b1111
        result = {
            0x00: '入金 ({})',
            0x01: 'なし ({})',
        }.get(discount, '不明 ({})')
        return result.format(hex(discount))

    def fetch_balance(self):
        return self.row_be[8]

    def format_time(self, usage_time):
        hex_time = hex(usage_time)
        int_time = int(hex_time, 16)
        origin_time = int_time * 10
        hm = divmod(origin_time, 60)
        return '{hm[0]:02d}:{hm[1]:02d}:00'.format(hm=hm)


def connected(tag):
    sc = nfc.tag.tt3.ServiceCode(KURURU_SERVICE_CODE >> 6, KURURU_SERVICE_CODE & 0x3f)
    for i in range(0, 10):
        bc = nfc.tag.tt3.BlockCode(i, service=0)
        data = tag.read_without_encryption([sc], [bc, ])

        history = HistoryRecord(bytes(data))
        if history.is_empty():
            continue

        result = """
        Block: {history_no}
        日付: {yyyy}/{mm}/{dd}
        機番: {machine}
        乗車時刻: {boarding_time}
        乗車停留所: {boarding_stop}
        降車時刻: {alighting_time}
        降車停留所: {alighting_stop}
        場所: {place}
        種別: {category}
        会社: {company}
        割引: {discount}
        残高: {balance:,}円
        """.format(
            history_no=i + 1,
            yyyy=history.fetch_year() + 2000,
            mm='{:02d}'.format(history.fetch_month()),
            dd='{:02d}'.format(history.fetch_day()),
            machine=history.fetch_machine_no(),
            boarding_time=history.fetch_boarding_time(),
            boarding_stop=history.fetch_boarding_stop(),
            alighting_time=history.fetch_alighting_time(),
            alighting_stop=history.fetch_alighting_stop(),
            place=history.fetch_place(),
            category=history.fetch_category(),
            company=history.fetch_company(),
            discount=history.fetch_discount(),
            balance=history.fetch_balance(),
        )
        print '-' * 30
        print textwrap.dedent(result)


def main():
    with nfc.ContactlessFrontend('usb') as clf:
        clf.connect(rdwr={'on-connect': connected})


if __name__ == '__main__':
    main()

 

実行結果

実行してみたところ、Robutoの時と同じ内容になりました*2

$ python kururu_reader.py 
------------------------------

Block: 1
日付: 2015/01/30
機番: 3072
乗車時刻: 11:20:00
乗車停留所: 3136
降車時刻: 11:50:00
降車停留所: 1
場所: 車内 (0x5)
種別: 支払 (0x2)
会社: アルピコバス (0x3)
割引: なし (0x1)
残高: 1,500円

------------------------------

Block: 2
日付: 2015/01/30
機番: 3039
乗車時刻: 08:20:00
乗車停留所: 1
降車時刻: 08:30:00
降車停留所: 3506
場所: 車内 (0x5)
種別: 支払 (0x2)
会社: アルピコバス (0x3)
割引: なし (0x1)
残高: 1,860円

------------------------------

Block: 3
日付: 2013/10/12
機番: 3
乗車時刻: 19:20:00
乗車停留所: 0
降車時刻: 19:20:00
降車停留所: 0
場所: 券売機 (0xe)
種別: 入金 (0x0)
会社: 長電バス (0x0)
割引: 入金 (0x0)
残高: 2,160円

------------------------------

Block: 4
日付: 2013/10/12
機番: 3058
乗車時刻: 18:50:00
乗車停留所: 3271
降車時刻: 19:00:00
降車停留所: 3379
場所: 車内 (0x5)
種別: 支払 (0x2)
会社: アルピコバス (0x3)
割引: なし (0x1)
残高: 1,160円

------------------------------

Block: 5
日付: 2013/10/12
機番: 1002
乗車時刻: 13:00:00
乗車停留所: 1632
降車時刻: 13:00:00
降車停留所: 1422
場所: 車内 (0x5)
種別: 支払 (0x2)
会社: 不明 (0x1)
割引: なし (0x1)
残高: 1,330円

------------------------------

Block: 6
日付: 2013/10/12
機番: 0
乗車時刻: 09:40:00
乗車停留所: 0
降車時刻: 09:40:00
降車停留所: 0
場所: 営業所 (0x7)
種別: 入金 (0x0)
会社: アルピコバス (0x3)
割引: 入金 (0x0)
残高: 1,500円

 

ソースコード

GitHubに上げました。 felica/kururu_reader.py ファイルが今回のものです。
https://github.com/thinkAmi-sandbox/nfcpy-sample

*1:知っている人から見れば基本的なことかもしれませんが...

*2:あの時以降、2回KURURUを使っているため、履歴が増えています