音声強化版PaPeRo i とAndroidでNDEFデータ通信を行う(NFCカードエミュレーション)

 音声強化版PaPeRo i のTinker Board S にソニー製カードリーダーライターPaSoRi RC-S380を接続してNFCカードエミュレーションを行い、AndroidへNDEFデータ送信を行う方法について調べました。
音声強化版PaPeRo i とAndroidでNDEFデータ通信を行う(NFCリーダーライター)もご参照ください

nfcpyでカードエミュレーション

 PythonでNFCを扱うにはnfcpyを利用します。長らくPython2専用だったと思うのですが2019年からPython3に対応していた様です。音声強化版パペロのTinker Board S のデフォルトのPythonにもpip3コマンドで普通にインストールできました。

pip3 install nfcpy

import名はnfcpyではなくnfcです。カードエミュレーションについては公式ページGetting startedのEmulate a cardという章で説明されています。2020/4時点で、

  • nfcpyのカードエミュレーションはType3限定
  • 対応デバイスも限られるがRC-S380であればType3タグエミュレーションがフルサポートされる(他のリーダライタでは制約がある)

とのことです。処理手順は

(1) NFCカードとしてのデータ用バッファを用意してデータを書き込む
(2) 接続(タッチ)時のコールバック関数を指定して接続待ちAPI(connect)を呼ぶ
(3) 接続コールバック関数内でデータリードコールバック(+データライトコールバック)を指定
(4) ブロック番号指定でリードコールバックが呼ばれる

という流れになっていてサンプルコードも提示されています。読まれるデータを変更しない場合はサンプルコードそのままで良さそうですが、リードコールバックはブロックごとに非同期で呼ばれるため、複数ブロックにまたがるデータを動作中に変更したい場合には排他制御が必要になります。その場合データ更新処理はNFC通信を阻害しない様十分短い時間で終わるようにする必要がありそうです。

NDEFデータの生成

 NDEFデータの生成にはnfcpyと同時にインストールされるndeflib(import名ndef)を利用します。例えばテキストレコード一つだけのNDEFメッセージは

lst = [ndef.TextRecord('hello world', 'en', 'UTF-8')]
dat = b''.join(ndef.message_encoder(lst))

で生成でき、これを先ほどのNFCカードとしてのデータ用バッファに書き込めばNFCリーダでNDEFデータとして読めるはずです。

クラス化

 上記を組み合わせて、テキストをセットして実行するとNDEFテキストレコードがNFCリーダで読める様になるクラスにしてみました。

card = NdefCardEmu()
card.set_ndef_text('Hello World')
card.run()

という感じで使えます。簡単のため排他制御は入れていないので動作中のデータの書き換えはできません。

ndefcardemu.py

import struct
import time
from logging import (getLogger, basicConfig, Formatter,
                     DEBUG, INFO, WARN, ERROR, CRITICAL)

import ndef
import nfc

logger = getLogger(__name__)

NDEF_AIB_SIZE_OFS = 11  # 3byte、bigendianでndefデータ長を書く
NDEF_AIB_DATA_OFS = 16  # データオフセット(==AIB長)


class NdefCardEmu:
    def __init__(self, dev='usb:054c:06c3', idm='03FEFFE011223344',
                 pmm='01E0000000FFFF00', sys='12FC', brty='212F',
                 open_retry_sec=10.0):
        self.dev = dev
        self.idm = idm
        self.pmm = pmm
        self.sys = sys
        self.brty = brty
        self.open_retry_sec = open_retry_sec
        self.fin = False
        # AIBブロックを書き込む
        bs = 128 * 3 * 16
        # self.ndef_data_area = bytearray(64 * 16)
        self.ndef_data_area = bytearray(bs)
        self.ndef_data_area[0] = 0x10  # NDEF mapping version '1.0'
        self.ndef_data_area[1] = 12    # Number of blocks that may be read at once
        self.ndef_data_area[2] = 8     # Number of blocks that may be written at once
        # self.ndef_data_area[4] = 63    # Number of blocks available for NDEF data
        self.ndef_data_area[3] = 0xff & ((bs-1) >> 8)    # Number of blocks available for NDEF data
        self.ndef_data_area[4] = 0xff & ((bs-1) >> 0)    # Number of blocks available for NDEF data
        self.ndef_data_area[10] = 1    # NDEF read and write operations are allowed
        # まだ[11:13]のlengthを書いていない
        #cs = sum(self.ndef_data_area[0:14])
        #self.ndef_data_area[14:16] = struct.pack('>H', cs)  # Checksum

    def set_aib_sum(self):
        # set aib checksum
        cs = sum(self.ndef_data_area[0:14])
        self.ndef_data_area[14:16] = struct.pack('>H', cs)  # Checksum

    def set_aib_length(self, v):
        # set aib length
        lp = NDEF_AIB_SIZE_OFS
        self.ndef_data_area[lp+0] = 0xff & (v >> (8 * (2 - 0)))
        self.ndef_data_area[lp+1] = 0xff & (v >> (8 * (2 - 1)))
        self.ndef_data_area[lp+2] = 0xff & (v >> (8 * (2 - 2)))
        # set aib checksum
        self.set_aib_sum()

    def ndef_read(self, block_number, rb, re):
        logger.debug('block={} rb={} re={}'.format(block_number, rb, re))
        if block_number < len(self.ndef_data_area) / 16:
            first, last = block_number*16, (block_number+1)*16
            block_data = self.ndef_data_area[first:last]
            return block_data

    def ndef_write(self, block_number, block_data, wb, we):
        if block_number < len(self.ndef_data_area) / 16:
            first, last = block_number*16, (block_number+1)*16
            self.ndef_data_area[first:last] = block_data
            return True

    def on_startup(self, target):
        logger.info('on_startup')
        idm, pmm, sys = self.idm, self.pmm, self.sys
        target.sensf_res = bytearray.fromhex('01' + idm + pmm + sys)
        target.brty = self.brty
        return target

    def on_connect(self, tag):
        logger.info('tag activated')
        tag.add_service(0x0009, self.ndef_read, self.ndef_write)
        tag.add_service(0x000B, self.ndef_read, lambda: False)
        self.on_tag_connected(tag)
        return True

    def on_tag_connected(self, tag):
        # タッチ開始イベント。
        # 必要なら派生クラスでオーバーライドしてください
        # raise NotImplementedError
        pass

    def set_raw_data(self, dat):
        # set raw data
        cnt = len(dat)
        ms = len(self.ndef_data_area)
        ofs = NDEF_AIB_DATA_OFS
        wlen = 0
        if (cnt + ofs) >= ms:
            logger.error('size over: {} >= {}'.format(cnt + ofs, ms))
            self.ndef_data_area[ofs:] = dat[0:ms]
            wlen = ms
        else:
            # logger.info('size: {} < {}'.format(cnt + ofs, ms))
            self.ndef_data_area[ofs:ofs+cnt] = dat
            wlen = cnt
        self.set_aib_length(wlen)

    def set_ndef_text(self, v):
        msg = None
        if isinstance(v, str):
            msg = [ndef.TextRecord(v)]
        elif isinstance(v, list) or isinstance(v, tuple):
            msg = [ndef.TextRecord(x) for x in v]
        if msg is not None:
            dat = b''.join(ndef.message_encoder(msg))
            self.set_raw_data(dat)
            return len(dat)
        return 0

    def quit(self):
        self.fin = True

    def clf_terminate(self):
        return self.fin

    def run(self):
        logger.info('start')
        while not self.fin:
            try:
                with nfc.ContactlessFrontend(self.dev) as clf:
                    card = {
                        'on-startup': self.on_startup,
                        'on-connect': self.on_connect,
                    }
                    while clf.connect(card=card, terminate=self.clf_terminate):
                        logger.info('tag released')
                        self.on_tag_released()
                        if self.fin:
                            break
            except OSError as e:
                logger.error(e)
                if e.errno == 19:
                    time.sleep(self.open_retry_sec)
                    logger.info('retry open.')
                else:
                    break
            except Exception as e:
                logger.error(e)
                break
        logger.info('end')

    def on_tag_released(self):
        # 必要なら派生クラスでオーバーライドしてください
        # raise NotImplementedError
        pass

def _test():
    card = NdefCardEmu()
    card.set_ndef_text('Hello World')
    card.run()

if __name__ == '__main__':
    loglevel = INFO
    LOG_FMT = '%(asctime)s %(levelname)s %(thread)d.%(name)s.%(funcName)s %(message)s'
    basicConfig(level=loglevel, format=LOG_FMT)
    _test()

AndroidでNDEFデータを読む

 AndroidでNFCのNDEFデータを読むアプリを作成するにはAndroidManifest.xmlにintent-filterの指定を追加します。これによりNDEFデータが読めたイベントによってアプリが起動される様になります。getNdefText()でテキストが取得できるはずなのですが、先頭に
0x02 0x65(‘e’) 0x6e(‘n’)
という3バイトのデータがついて来てしまいます。これはNDEFテキストレコードのヘッダで先頭の0x02は

  • UTF-8
  • 後ろに続くlanguage指定が2byte長

ということを意味しておりそのlanguage指定が”en”です。このNDEFテキストレコードのヘッダについてはAndroid側では処理されずアプリレベルで対応する必要があるという事になりますが今回の例で言えば先頭の3バイトを単にスキップすれば十分です。

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.ndefread002">

    <uses-permission android:name="android.permission.NFC" />
    <uses-feature
        android:name="android.hardware.nfc"
        android:required="true" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <intent-filter>
                <action android:name="android.nfc.action.NDEF_DISCOVERED" />
                <category android:name="android.intent.category.DEFAULT" />
                <data android:mimeType="text/plain" />
            </intent-filter>
        </activity>
    </application>
</manifest>

MainActivity.java

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Button clearbtn = findViewById(R.id.button);
        clearbtn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                TextView tv = (TextView)findViewById(R.id.textView);
                tv.setText("");
            }
        });

        Intent intent = getIntent();
        String action = intent.getAction();
        if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(action)
                ||  NfcAdapter.ACTION_TECH_DISCOVERED.equals(action)
                ||  NfcAdapter.ACTION_TAG_DISCOVERED.equals(action)) {

            // IDmを表示
            //String txt = getIdm(getIntent());
            // NDEFデータを表示
            String txt = getNdefText(getIntent());
            if (txt != null) {
                TextView txtview = (TextView) findViewById(R.id.textView);
                txtview.setText(txt);
            }
        }
    }

    private String getIdm(Intent intent) {
        String idm = null;
        StringBuffer idmByte = new StringBuffer();
        byte[] rawIdm = intent.getByteArrayExtra(NfcAdapter.EXTRA_ID);
        if (rawIdm != null) {
            for (int i = 0; i < rawIdm.length; i++) {
                idmByte.append(Integer.toHexString(rawIdm[i] & 0xff));
            }
            idm = idmByte.toString();
        }
        return idm;
    }

    private String getNdefText(Intent intent) {
        String txt = null;
        StringBuffer buf = new StringBuffer();
        Parcelable[] dat = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES);
        NdefMessage[] msgs = new NdefMessage[dat.length];
        for (int j = 0; j < dat.length; j++) {
            msgs[j] = (NdefMessage)dat[j];
            for (NdefRecord record : msgs[j].getRecords()) {
                String nt = new String(record.getType());
                buf.append(String.format("Type : %s\n", nt));
                buf.append(String.format("TNF : %s\n", record.getTnf()));
                byte[] payload = record.getPayload();
                if (payload == null) {
                    break;
                }
                if (!nt.equals("T")) {
                    buf.append(String.format("Length : %d", payload.length));
                    break;
                }
                // text type
                // テキストの場合先頭に 0x02, 'e', 'n' の様な言語指定がつくがAndroidは対応していないらしい
                int idx = 0;
                int startp = payload[0];
                buf.append("LANG:");
                for (byte data : payload) {
                    buf.append(String.format("%c", data));
                    if (idx == startp) {
                        buf.append(String.format("\nText[%d]:\n", payload.length - startp));
                    }
                    idx++;
                }
            }
        }
        if (buf != null) {
            txt = buf.toString();
        }
        return txt;
    }
}

※端末がFelicaに対応している必要があります。Nexus7 (2013)(Android6.0.1)で動作確認を行いました。


0