はじめに
ラズパイのGPIOピンに格安NFCモジュールボードを接続し、I2Cプロトコルで通信を行った。よく紹介されているnfcpy
はUSBで接続したデバイスを制御するもので、今回は使わない。
購入したモジュールはELECHOUSE製
のNFC MODULE V3
。Amazonで700円ちょっと(2023年9月時点)。少し前は半導体不足の影響か高騰してたけど、少し落ち着いてきたのかな?海を渡ってくるので到着までに2週間程度かかった。
モジュールの設定
ピンが付属してきたので適当にハンダ付け。ボード上のスイッチは「1」を「ON」とした。
ラズパイとの接続
Vcc - 3.3V(1番ピン)
、SDA - SDA(3番ピン)
、SCL - SCL(5番ピン)
、GND - GND(9番ピン)
で接続する。5Vピンに接続している画像を見ることがあるが、このボードは3V動作なので3.3Vに接続しても動作する。・・・と記載していたが3.3Vだとどうも動作が不安定。結局5Vピン(2番ピン)に接続した。写真は古いまま。
ラズパイ4のピン配列。以下サイトで確認。
以下は接続部の写真。GND
のピン(茶線)は1つ間をあけていることに注意。
■ ■ ■ □ ■
と1つピンを飛ばしてますよ~ってのを撮りたかったけどわかりにくい!上の赤黒線はCPUファンなので気にしないでくだされ。
I2CとかSPIって?
通信の規格。通信方法によって必要になるドライバ(Pythonライブラリ)が異なる。I2C
は2本の信号線で通信しSDA
でデータを、SCL
でクロックをやり取りする。小難しい話になるので詳細はググってくださいませ。ラズパイとの接続方法が理解できれば良いと思う。
SPI:高速。超正確なリアルタイム性が要求される場面。
I2C:そこそこ高速。Pythonコードはシンプル。
ということで汎用性のあるI2C
を選びました。
ラズパイの設定
raspi-config
3 Interface Options
から、I5 I2C
を選択する。
Would you like the ARM I2C interface to be enabled?
と聞いてくるのでYes
返答。
タブキーで<Finish>
を選択して対話モードを終え、ラズパイを再起動する。
認識されているか確認
ls /dev
を実行すると
i2c-1
i2c-20
i2c-21
と表示され認識されているっぽい。続いてI2C-Tool
を利用してバスアドレスを確認する。引数に渡す数値は/dev/i2c-*
で認識されている数値を指定する。
i2cdetect -y 1
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
ということになる。残り二つもやってみると、
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
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
うーーん、わからん。
ちなみに、認識されていないバスの値で実行すると、
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)のラズパイ用ドライバを使ってみた。
pip3 install adafruit-circuitpython-pn532
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ライセンスらしい。
動作確認
vi testi2c
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
を押下する。
python3 testi2c
nfcデバイスを近づけると、
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
クラスの中でメソッドが色々あったのでまとめ。
# UIDが読めたらTrue:Falseが返される。
get_passive_target(timeout=1)
# IRQ(割り込み処理)のときに使うらしい。
listen_for_passive_target(timeout=1)
# 普段はこれ。UIDが返ってくる。
read_passive_target(timeout=1)
systemdで自動起動しない
import board
import busio
from adafruit_pn532.i2c import PN532_I2C
と記述するが、起動時自動実行しようとするとboard
がインポートできん!と怒られる。
これを回避するために、
import sys
sys.path.append("/home/*****appuser*****/.local/lib/python3.9/site-packages")
と最初に記述した。ただしこの記述は否定的な意見もある様子。今回は使用する環境が限定されているし、コード触るのも1人だしまぁ許してくれぃ。
/etc/sysemd/system
配下のhoge.service
は以下を記述。
[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
で書くと、ループの処理が終わらない限り画面は表示されない。
# メインメニュー画面
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へ登録などの独立した処理は可能。ちょっと説明だけではわかりにくいので中止ボタンの実装のところで具体例を出します。
import threading
~ 途中省略
# デバイスIDの読取を開始
- self.start_countdown() # これは削除するかコメントアウト
+ countdown_thread = threading.Thread(target=self.start_countdown)
+ countdown_thread.start()
読み取り中止ボタンの実装
中止ボタンを実装してみる。非同期で呼び出したstart_countdown
に対して単純にself.window.destroy()
をコールするだけでは別スレッドを停止することはできない。
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)
def cancel_reading(self):
self.text_label.config(text="中止しました")
time.sleep(3)
self.window.destroy() # ガベージコレクタに期待したが無理っぽい
この記述でも画面は閉じるが、閉じた後もバックグラウンドでforループが残り続けてしまう。今回のコードは上限があるループだが、無限ループだと悲惨。そこでforループ内で継続を判定させる変数を使うことで解決した。
# __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
非同期で呼び出した関数はその関数内で処理しないと反映されない。中止ボタン実装にすら注意が必要だったというお話でした。
参考