【Python】Raspberry pi 4で格安PN532ボードを動かす【NFC】

# はじめに
　ラズパイのGPIOピンに格安NFCモジュールボードを接続し、I2Cプロトコルで通信を行った。よく紹介されている`nfcpy`はUSBで接続したデバイスを制御するもので、今回は使わない。

　購入したモジュールは`ELECHOUSE製`の`NFC MODULE V3`。Amazonで700円ちょっと（2023年9月時点）。少し前は半導体不足の影響か高騰してたけど、少し落ち着いてきたのかな？海を渡ってくるので到着までに2週間程度かかった。

# モジュールの設定
　ピンが付属してきたので適当にハンダ付け。ボード上のスイッチは「1」を「ON」とした。

![20231014_110004.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1248818/bea9c080-ffd6-d854-0355-bb6270a2768d.jpeg)


# ラズパイとの接続
　`Vcc - 3.3V（1番ピン）`、`SDA - SDA（3番ピン）`、`SCL - SCL（5番ピン）`、`GND - GND（9番ピン）`で接続する。5Vピンに接続している画像を見ることがあるが、このボードは3V動作なので3.3Vに接続しても動作する。・・・と記載していたが3.3Vだとどうも動作が不安定。結局5Vピン（2番ピン）に接続した。写真は古いまま。

　ラズパイ4のピン配列。以下サイトで確認。

https://qiita.com/Pumila/items/f362a3d68e57cd088713

https://raspberry-pi.ksyic.com/bypass/files/GPIO.svgz#Alt0

　以下は接続部の写真。`GND`のピン（茶線）は1つ間をあけていることに注意。

![20231014_123746.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1248818/51ffa489-263e-ac6d-ddab-c00f74bcbfca.jpeg)

　`■ ■ ■ □ ■`と1つピンを飛ばしてますよ～ってのを撮りたかったけどわかりにくい！上の赤黒線はCPUファンなので気にしないでくだされ。

![20231014_123955.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1248818/1956f892-039b-ebd7-5d5b-483502b1105d.jpeg)


## I2CとかSPIって？
　通信の規格。通信方法によって必要になるドライバ（Pythonライブラリ）が異なる。`I2C`は2本の信号線で通信し`SDA`でデータを、`SCL`でクロックをやり取りする。小難しい話になるので詳細はググってくださいませ。ラズパイとの接続方法が理解できれば良いと思う。

```
SPI：高速。超正確なリアルタイム性が要求される場面。
I2C：そこそこ高速。Pythonコードはシンプル。
```

ということで汎用性のある`I2C`を選びました。


# ラズパイの設定

```bash:rootで実行
raspi-config
```

　`3 Interface Options`から、`I5 I2C`を選択する。

![001.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1248818/33b44356-5589-2189-f8d1-ebfd17080f9f.jpeg)

![002.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1248818/1b81c631-c309-829d-cef8-a4afe0dcde6b.jpeg)


`Would you like the ARM I2C interface to be enabled?`と聞いてくるので`Yes`返答。

![003.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1248818/c3a2d1a4-f4db-c62a-e525-0469c236c4db.jpeg)

![004.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1248818/cc4e5d6d-6eae-804f-1c37-ee5d1d71e39c.jpeg)

タブキーで`<Finish>`を選択して対話モードを終え、ラズパイを再起動する。

![005.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1248818/e414fa3b-b9fd-9cde-7b09-d4d0cbbfdd76.jpeg)



# 認識されているか確認 

`ls /dev`を実行すると

```bash:ls /dev 実行結果（i2cだけ抜粋）
i2c-1
i2c-20
i2c-21
```

　と表示され認識されているっぽい。続いて`I2C-Tool`を利用してバスアドレスを確認する。引数に渡す数値は`/dev/i2c-*`で認識されている数値を指定する。

```bash
i2cdetect -y 1
```

```bash:実行結果
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- -- 
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
20: -- -- -- -- 24 -- -- -- -- -- -- -- -- -- -- -- 
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
```

　と表示された。バスアドレスは`0x24`ということになる。残り二つもやってみると、

```bash:i2cdetect -y 20 実行結果
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         08 09 0a 0b 0c 0d 0e 0f 
10: 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 
20: 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
30: -- -- -- -- -- -- -- 37 38 39 3a 3b 3c 3d 3e 3f 
40: 40 41 42 43 44 45 46 47 48 49 4a 4b 4c 4d 4e 4f 
50: 50 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
60: 60 61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 
70: 70 71 72 73 74 75 76 77
```

```bash:i2cdetect -y 21 実行結果
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         08 09 0a 0b 0c 0d 0e 0f 
10: 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 
20: 20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 
30: -- -- -- -- -- -- -- -- 38 39 3a 3b 3c 3d 3e 3f 
40: 40 41 42 43 44 45 46 47 48 49 4a 4b 4c 4d 4e 4f 
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
60: 60 61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 
70: 70 71 72 73 74 75 76 77
```

うーーん、わからん。
ちなみに、認識されていないバスの値で実行すると、

```bash
i2cdetect -y 2

Error: Could not open file `/dev/i2c-2' or `/dev/i2c/2': No such file or directory
```

となる。エラーメッセージから察するに、`/dev/i2c-*`の中身を表示してるだけなのかな。


# ドライバのインストール

　`Elechouse`の公開ライブラリは`Arduino`用。さすがにハードが違うと動作しなさそうなので、今回は別のボード（Adafruit）のラズパイ用ドライバを使ってみた。

```bash
pip3 install adafruit-circuitpython-pn532
```

```bash:実行結果
Looking in indexes: https://pypi.org/simple, https://www.piwheels.org/simple
Collecting adafruit-circuitpython-pn532
  Downloading https://www.piwheels.org/simple/adafruit-circuitpython-pn532/adafruit_circuitpython_pn532-2.3.20-py3-none-any.whl (15 kB)
Collecting adafruit-circuitpython-typing
  Downloading https://www.piwheels.org/simple/adafruit-circuitpython-typing/adafruit_circuitpython_typing-1.9.5-py3-none-any.whl (10 kB)
Requirement already satisfied: pyserial in /usr/lib/python3/dist-packages (from adafruit-circuitpython-pn532) (3.5b0)
Collecting typing-extensions~=4.0
  Downloading https://www.piwheels.org/simple/typing-extensions/typing_extensions-4.8.0-py3-none-any.whl (31 kB)
Collecting adafruit-circuitpython-busdevice
  Downloading https://www.piwheels.org/simple/adafruit-circuitpython-busdevice/adafruit_circuitpython_busdevice-5.2.6-py3-none-any.whl (7.5 kB)
Collecting Adafruit-Blinka
  Downloading https://www.piwheels.org/simple/adafruit-blinka/Adafruit_Blinka-8.23.0-py3-none-any.whl (310 kB)
     |████████████████████████████████| 310 kB 92 kB/s 
Requirement already satisfied: RPi.GPIO in /usr/lib/python3/dist-packages (from Adafruit-Blinka->adafruit-circuitpython-pn532) (0.7.0)
Collecting Adafruit-PureIO>=1.1.7
  Downloading https://www.piwheels.org/simple/adafruit-pureio/Adafruit_PureIO-1.1.11-py3-none-any.whl (10 kB)
Collecting sysv-ipc>=1.1.0
  Downloading sysv_ipc-1.1.0.tar.gz (99 kB)
     |████████████████████████████████| 99 kB 3.0 MB/s 
WARNING: Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'ProtocolError('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))': /simple/adafruit-platformdetect/
Collecting Adafruit-PlatformDetect>=3.52.0
  Downloading https://www.piwheels.org/simple/adafruit-platformdetect/Adafruit_PlatformDetect-3.53.0-py3-none-any.whl (23 kB)
Collecting pyftdi>=0.40.0
  Downloading https://www.piwheels.org/simple/pyftdi/pyftdi-0.55.0-py3-none-any.whl (145 kB)
     |████████████████████████████████| 145 kB 101 kB/s 
Collecting rpi-ws281x>=4.0.0
  Downloading rpi_ws281x-5.0.0.tar.gz (64 kB)
     |████████████████████████████████| 64 kB 3.1 MB/s 
Requirement already satisfied: pyusb!=1.2.0,>=1.0.0 in /usr/lib/python3/dist-packages (from pyftdi>=0.40.0->Adafruit-Blinka->adafruit-circuitpython-pn532) (1.0.2)
Collecting adafruit-circuitpython-requests
  Downloading https://www.piwheels.org/simple/adafruit-circuitpython-requests/adafruit_circuitpython_requests-2.0.2-py3-none-any.whl (11 kB)
Building wheels for collected packages: rpi-ws281x, sysv-ipc
  Building wheel for rpi-ws281x (setup.py) ... done
  Created wheel for rpi-ws281x: filename=rpi_ws281x-5.0.0-cp39-cp39-linux_aarch64.whl size=125987 sha256=e765bdff1c0f94f5b50ed2d2bf7432cd371f61de9bf9d71dae33c0f0c7e51bf5
  Stored in directory: /home/xxxxxxx/.cache/pip/wheels/ad/58/49/123e562899645d5bd2c123fdc8af1564c0c38cbb5bebeda859
  Building wheel for sysv-ipc (setup.py) ... done
  Created wheel for sysv-ipc: filename=sysv_ipc-1.1.0-cp39-cp39-linux_aarch64.whl size=65880 sha256=29ce043de19dd5763af5db6b41ef0cc49d9a167217598a8a131d2949aa15330a
  Stored in directory: /home/xxxxxxx/.cache/pip/wheels/37/7f/8b/b3a69bb6cc69f370d4264547e56d7117a8472e5e691c8b8e11
Successfully built rpi-ws281x sysv-ipc
Installing collected packages: sysv-ipc, rpi-ws281x, pyftdi, Adafruit-PureIO, Adafruit-PlatformDetect, typing-extensions, adafruit-circuitpython-requests, Adafruit-Blinka, adafruit-circuitpython-typing, adafruit-circuitpython-busdevice, adafruit-circuitpython-pn532
Successfully installed Adafruit-Blinka-8.23.0 Adafruit-PlatformDetect-3.53.0 Adafruit-PureIO-1.1.11 adafruit-circuitpython-busdevice-5.2.6 adafruit-circuitpython-pn532-2.3.20 adafruit-circuitpython-requests-2.0.2 adafruit-circuitpython-typing-1.9.5 pyftdi-0.55.0 rpi-ws281x-5.0.0 sysv-ipc-1.1.0 typing-extensions-4.8.0
```

　なんか途中でエラーが出とるな～...。購入したボードは`Adafruit製`では無い。動作してくれると良いのだが...（不安）。ドライバはMITライセンスらしい。


# 動作確認

```bash
vi testi2c
```

```python
import board
import busio
from adafruit_pn532.i2c import PN532_I2C

# PN532モジュールの初期化
i2c = busio.I2C(board.SCL, board.SDA)
pn532 = PN532_I2C(i2c, debug=False, irq=None)
pn532.SAM_configuration()

while True:
    # NFCカードの検出を待つ
    uid = pn532.read_passive_target(timeout=0.5)

    if uid is not None:
        print("NFCカードが検出されました!") 
        print("UID:", [hex(i) for i in uid])
        break
```

　プログラムを実行していざ読み取りテスト。プログラムから抜けるには、`Ctrl + C`を押下する。

```bash
python3 testi2c
```

　nfcデバイスを近づけると、

```bash
NFCカードが検出されました!
UID: ['0xcc', '0xb7', '0x7', '0x38']
```

　IDが表示された！試しにスマホを近づけてみると同様の反応。めでたしめでたし。

 `adafruit-circuitpython-pn532`の公式ドキュメントを見た感じ、めっちゃ機能（関数）が多い。色々なことができそうです。

　一度だけプログラム実行時に`RuntimeError: Did not receive expected ACK from PN532!`というエラー（他にもいっぱい何か表示されてた）が出て実行できなかったけど未解明。その後すぐに実行したら普通に起動された。エラーとなった行は`i2c = busio.I2C(board.SCL, board.SDA)`。実際にコードを書くときはエラーをスローするように書いた方が良さそう。
　後にPN532ドライバのコードとにらめっこ、ChatGPTさんの助けも得ながらどうやら電源電圧不足で動作が不安定なのではないかという疑惑。5Vピンに接続を変更した。


# Pythonで当たった壁

　はい、実はpythonって構文の学習だけ数年前にやって、今回初めて本格的に触りました。んで理解に苦労した部分をメモ的に書いておきます。

　人気の言語らしいけど、初めての言語がpythonってのは難易度が高いと思う（変数型に厳密なのに宣言しない、クラスメソッドと関数の呼び出しが同じ記述）。オブジェクトとかクラスとかが解ってると、pythonってすごい楽な言語だなぁと思いました。

## UID読み取りに使うメソッド

　`adafruit_pn532`クラスの中でメソッドが色々あったのでまとめ。

```python
# UIDが読めたらTrue:Falseが返される。
get_passive_target(timeout=1)

# IRQ（割り込み処理）のときに使うらしい。
listen_for_passive_target(timeout=1)

# 普段はこれ。UIDが返ってくる。
read_passive_target(timeout=1)
```


## systemdで自動起動しない

```python
import board
import busio
from adafruit_pn532.i2c import PN532_I2C
```

と記述するが、起動時自動実行しようとすると`board`がインポートできん！と怒られる。

これを回避するために、

```python
import sys
sys.path.append("/home/*****appuser*****/.local/lib/python3.9/site-packages")
```

と最初に記述した。ただしこの記述は否定的な意見もある様子。今回は使用する環境が限定されているし、コード触るのも1人だしまぁ許してくれぃ。

 `/etc/sysemd/system`配下の`hoge.service`は以下を記述。
 
```bash
[Unit]
Description=NFC Reader
After=graphical.target
Requires=graphical.target

[Service]
Type=simple
Restart=on-failure
RestartSec=10
Environment="DISPLAY=:0"
ExecStart=/usr/bin/python3 /home/app/app.py

[Install]
WantedBy=graphical.target
```

## もうちょっと詳しいコード実用例
　pythonはシングルスレッドで実行される言語のため、ループ構文の使い方には注意が必要。画面を表示してRFIDを読み取るコードを`while`や`for`で書くと、ループの処理が終わらない限り画面は表示されない。

```python:具体例（このままでは使えない）
# メインメニュー画面
class Main:
    def __init__(self):
        # 画面設定
        self.window = tk.Tk()
        self.window.title("メイン画面")

        # 全画面表示
        screen_width = self.window.winfo_screenwidth()
        screen_height = self.window.winfo_screenheight()
        self.window.geometry(f"{screen_width}x{screen_height}")

        # ボタン設置：クリックでopen_read_windowを呼び出す
        self.button = tk.Button(self.window, text="ボタン", command=self.open_read_window, font=("IPAexGothic", 14), width=14, height=4)
        self.button.pack(side=tk.BOTTOM)

    def open_read_window(self):
        start_reading_window = ReadWindow(self.window)

# RFID読み取り画面
class ReadWindow:
    def __init__(self, parent):
        # 画面設定
        self.parent = parent
        self.window = tk.Toplevel(parent)
        self.window.title('RFID読み取り')

        # 全画面表示
        screen_width = self.parent.winfo_screenwidth()
        screen_height = self.parent.winfo_screenheight()
        self.window.geometry(f"{screen_width}x{screen_height}+0+0")
        self.window.attributes("-topmost", True)    # 最前面表示

        # テキストラベル
        self.text_label = tk.Label(self.window, text="ID読み取り待機中です...", font=("IPAexGothic", 14))
        self.text_label.pack(expand=True)

        i2c = busio.I2C(board.SCL, board.SDA)                # PN532初期化
        self.pn532 = PN532_I2C(i2c, debug=False, irq=None)   # PN532初期化
        self.pn532.SAM_configuration()                       # PN532初期化

        # デバイスIDの読取を開始 （ここに注意：コードスパン内での色の変え方がわからん！）
        self.start_countdown()

    def start_countdown(self):
        countdown_seconds = 90        # カウントダウンの秒数タイマーをセット（秒数経過で自動終了）

        for countdown_seconds in range(countdown_seconds, -1, -1):
            # NFCタグの読み取り
            uid = self.pn532.read_passive_target(timeout=1)        # 1秒の読み取り待機（この数値を変更するとカウントダウン速度が変わります）
            if uid is not None:
                device_id = "".join([hex(i)[2:] for i in uid])     # uidを見やすい文字列フォーマットへ変換
                # ここに何かしらのメソッドを入れる
                # ReadWindow画面のラベルを変更したり、データベースに登録したり...
                # データベース登録の場合、セッション作成切断はループの外側で記述
                time.sleep(3)                                      # IDを読み取った事をユーザーへ理解させるため3秒画面を表示する
                break

        self.window.destroy()                # ループを抜けたら画面を自動で閉じる
```

　このような記述だと、`ReadWindow`のループで処理が止まってしまい画面が表示されなくなる。あとボタンを連射すると処理キューが大量に作られてアプリがフリーズするかも。そのために`ReadWindow`は最前面表示して物理的にボタンを押せなくしている。
　解決方法の一つとして`threading`を使う方法があるが、別スレッドとなるので呼び出した先から元々のスレッドに対する処理はできないらしい。DBへ登録などの独立した処理は可能。ちょっと説明だけではわかりにくいので中止ボタンの実装のところで具体例を出します。

```python:threadingを使う
import threading

～ 途中省略

        # デバイスIDの読取を開始
-        self.start_countdown()    # これは削除するかコメントアウト
+        countdown_thread = threading.Thread(target=self.start_countdown)
+        countdown_thread.start()
```

## 読み取り中止ボタンの実装
　中止ボタンを実装してみる。非同期で呼び出した`start_countdown`に対して単純に`self.window.destroy()`をコールするだけでは別スレッドを停止することはできない。

```python:ReadWindow画面の__init__に追記
        self.close_button = tk.Button(self.window, text="中止", font=("IPAexGothic", 12), command=self.cancel_reading, width=14, height=4)
        self.close_button.pack(side=tk.BOTTOM)
```

```python:ダメだった例
    def cancel_reading(self):
        self.text_label.config(text="中止しました")
        time.sleep(3)
        self.window.destroy()    # ガベージコレクタに期待したが無理っぽい
```

　この記述でも画面は閉じるが、閉じた後もバックグラウンドでforループが残り続けてしまう。今回のコードは上限があるループだが、無限ループだと悲惨。そこでforループ内で継続を判定させる変数を使うことで解決した。

```python:コードの修正
# __init__ に追記
        # デバイスIDの読取を開始
+       self.countdown_running = True        # 変数を追加
        countdown_thread = threading.Thread(target=self.start_countdown)
        countdown_thread.start()

# メソッドの修正
    def start_countdown(self):
        countdown_seconds = 90        # カウントダウンの秒数タイマーをセット（秒数経過で自動終了）
        
        for countdown_seconds in range(countdown_seconds, -1, -1):
            if self.countdown_running == True:
                pass
            else:
                self.close_button.config(state=tk.DISABLED)
                time.sleep(3)
                break

            # NFCタグの読み取り
            uid = self.pn532.read_passive_target(timeout=1)        # 1秒の読み取り待機（この数値を変更するとカウントダウン速度が変わります）
            if uid is not None:
                device_id = "".join([hex(i)[2:] for i in uid])     # uidを見やすい文字列フォーマットへ変換
                self.text_label.config(text="読み取り成功：" + device_id)
                self.countdown_running = False
                time.sleep(3)                                      # IDを読み取った事をユーザーへ理解させるため3秒画面を表示する
                break

        self.window.destroy()                # ループを抜けたら画面を自動で閉じる

    # 中止メソッドの追加（中止ボタンクリックで呼び出す）
    def cancel_reading(self):
        self.text_label.config(text="中止しました")
        self.countdown_running = False
```

　非同期で呼び出した関数はその関数内で処理しないと反映されない。中止ボタン実装にすら注意が必要だったというお話でした。

# 参考

https://docs.circuitpython.org/projects/pn532/en/latest/

https://github.com/adafruit/Adafruit_CircuitPython_PN532

https://www.elechouse.com/elechouse/images/product/PN532_module_V3/PN532_%20Manual_V3.pdf