学生証で研究室入退室報告SlackBot with ラズパイ

この記事は[Raspberry Pi Advent Calendar 2021](https://qiita.com/advent-calendar/2021/raspberrypi) 17日目の記事です。


## 作ったもの

研究室の入退室を報告するSlack Botを作りました。
iPadで入室/退室を選択してカードリーダに学生証をタッチすると、Slackのチャンネルでメンション共に入退室が報告されます。

<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://twitter.com/TOS?ref_src=twsrc%5Etfw">@tos</a> <a href="https://t.co/j0f7gSlIKO">pic.twitter.com/j0f7gSlIKO</a></p>&mdash; さくあ@思いつきぶん投げ(?) (@sakua_create) <a href="https://twitter.com/sakua_create/status/1471501412974891014?ref_src=twsrc%5Etfw">December 16, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


新型コロナの影響で、研究室の入室/退室の管理が必要になりました。
今までは、「Slackで手打ちで入室を報告→退室時に✅ リアクションをつける」という運用をしていましたが、これが結構面倒だったというのが作った動機です。

## 材料
* Raspberry Pi 4
* SONY PaSoRi RC-320
* iPad
* Slackアカウント

## システム

### 全体構成
![システム構成図.001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130420/8d3d2c18-a4f4-f57f-8a1e-b11d1484af3a.png)

### 流れ

**学生証登録済みの場合**

1. iPadで入室か退室かを選択
2. 学生証をカードリーダにタッチ
3. Slackの入退室チャンネルでメンションと共に報告される

**学生証未登録の場合**

1. 未登録の学生証をカードリーダにタッチ
2. 作成者(私)にSlackBotからIDm(識別番号的なもの)が届く
3. 作成者がタッチした人の
Slackのアカウントと学生証を結びつけたものをラズパイ内の環境変数に追加する
4. 登録完了！

ちなみにこんなメッセージが届くようにしました。
![未登録.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130420/03653eee-2fe3-26dd-1e5c-b44f061992c9.jpeg)

### ファイル構成
```
slackbot
    ├ .env (IDmとSlackのmember ID紐付け用環境変数)
    ├ libpafe (PaSoRi RC-320用のライブラリ)
    ├ server.py (Flaskサーバ)
    ├ idm_slack.py (IDm読み取り&Slack投稿)
    ├ state.txt (入退室状態保持ファイル)
    └ templates
        └ index.html (入退室切替用Webページ)
```

## 実装

### IDmとSlackのmember ID紐付け用環境変数

SlackのToken, Slackのmember ID, そして登録する学生証のIDmと登録者のSlackアカウントのmember IDのペアを環境変数に入れておきます。
どれも次の「IDm読み取り&Slackに投稿するプログラム」で使用します。

```
TOKEN="SlackのToken"
ADMIN_ID="自分のSlackのmember ID"
"登録者の学生証のIDm"=<@"登録者のSlackのmember ID">
・
・
・
```


### IDm読み取り&Slackに投稿するプログラム

PaSoRiからIDmを読み取って、Slackに投稿するプログラムです。

#### 環境
* Python 2.7
* libpafe (PaSoRi用ライブラリ)
* libusb-dev (libpafeの前提ライブラリ)
* python-dotenv (環境変数読み込み用ライブラリ)
* slacker (Slack投稿用ライブラリ)

研究室にあったICカードリーダPaSoRi RC-320は少し古いため、主流だと思われるnfcpyを使用できず、代わりにlibpafeを使用しました。libpafeはPythonの2系でしか動かないため、2系を使っています。
後述する「Flaskサーバ」は3系で動いているため、ラズパイ内には2種類のバージョンのPythonが共存していることになります。

```sh
$ sudo apt-get install libusb-dev
$ git clone https://github.com/rfujita/libpafe.git
$ cd libpafe
$ ./configure
$ make
$ sudo make install
$ pip install python-dotenv
$ pip install slacker
```


#### プログラム

プログラム作成にあたって、以下の記事を参考にさせて頂きました。

* [raspberrypiでFeliCaのIDmを読み取る（pasori RC-S320版）| Qiita](https://qiita.com/t114/items/819a9cdfe90ea98dd4d3)
* [Python を使って Slack に投稿 | manspace](https://nuxx-mans.com/python-%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6-slack-%E3%81%AB%E6%8A%95%E7%A8%BF/)

```idm_slack.py
# coding: utf-8

from dotenv import load_dotenv
load_dotenv()

import os
import time
from slacker import Slacker

# 環境変数からSlackのトークン取得
TOKEN = os.getenv('TOKEN')
slack = Slacker(TOKEN)

FELICA_POLLING_ANY = 0xffff

ADMIN_ID = os.getenv('ADMIN_ID')

state = 'enter'
state_statement = {
  'enter': '入室',
  'leave': '退室'
}

if __name__ == '__main__':
  # PaSoRiの初期設定
  libpafe = cdll.LoadLibrary("/usr/local/lib/libpafe.so")
  libpafe.pasori_open.restype = c_void_p
  pasori = libpafe.pasori_open()
  libpafe.pasori_init(pasori)

  try:
    while True:
      # ループでPaSoRiからのIDmを読み取る
      libpafe.felica_polling.restype = c_void_p
      felica = libpafe.felica_polling(pasori, FELICA_POLLING_ANY, 0, 0)
      idm = c_ulonglong()
      libpafe.felica_get_idm.restype = c_void_p
      libpafe.felica_get_idm(felica, byref(idm))

      # フォーマット
      idm_16 = "%016X" % idm.value

      # 読み取れてなかった場合ループの頭へ
      print(idm_16)
      if (idm_16) == "0000000000000000":
        time.sleep(1)
        continue

      # 読み取れた場合、環境変数ファイルでSlackのmember IDが紐づけられていないかどうか確認
      member = os.getenv(idm_16)
      if member is None:
        # 紐付けがなかった場合、新規登録とみなして作成者に通知
        slack.chat.post_message(ADMIN_ID, "{}が新しくカードをかざしました".format(idm_16), as_user=False)
      else:
        # 紐付けがあった場合、入室or退室の状態をテキストファイルから読み取り、メンションをつけてSlackへ投稿
        f = open('state.txt', 'r')
        state = f.read()
        f.close()
        slack.chat.post_message("#1108access_management","{}が{}しました".format(member,state_statement[state]) , as_user=False)

      libpafe.free(felica)
      time.sleep(5)
  except KeyboardInterrupt:
    libpafe.pasori_close(pasori)
    exit()

```

### Flaskサーバ

htmlファイルをホストするサーバです。
webページから発火される入室 / 退室の切替イベントを受け取って、状態保存用のテキストファイルを上書きしています。

#### 環境
* Python 3.6
* Flask (サーバ用)
* Flask-SocketIO (htmlファイルとのSocketIO通信用)

後述するwebページにある入室/退室のボタンの切り替えは、SocketIOに載せて通知しています。
そのイベントの発火を受け取るために、Flask-SocketIOを使っています。

```sh
$ pip install Flask
$ pip install flask-socketio
```

#### プログラム

プログラム作成にあたって以下の記事を参考にさせて頂きました。

* [Flask-SocketIO – Flaskアプリケーション用のSocket.IO統合](https://githubja.com/miguelgrinberg/flask-socketio)

```server.py
# coding: utf-8

from flask import Flask, render_template
from flask_socketio import SocketIO

app = Flask(__name__)
socketio = SocketIO(app)

# ルートにアクセスした際に表示するページ
@app.route('/')
def hello():
  html = render_template('index.html')
  print('connected')
  return html

# htmlファイルからの'state_from_client'イベントを監視
# イベントを受け取ったら、入退室状態保存用のファイルを書き換える
@socketio.on('state_from_client', namespace='/')
def receive_message(msg):
  f = open('state.txt', 'w')
  f.write(msg)
  f.close()
  print(msg)

if __name__ == '__main__':
  try:
    socketio.run(app, host='0.0.0.0', port=5000)
  except KeyboardInterrupt:
    exit()
```

### 入退室切替用Webページ

入退室状態を選択するUIを表示するhtmlファイルです。
入室/退室のボタンを押すとイベントが発火され、SocketIOを通じてサーバに伝えられます。

#### 環境
* jQuery 3.2.1
* Socket.IO 3.0.0
* Bootstrap 5.0.0

#### プログラム

```index.html
<html>
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<title>入退室UI</title>
  	
  <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
  <script src="https://cdn.socket.io/3.0.0/socket.io.min.js" crossorigin="anonymous"></script>

  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css"
    rel="stylesheet" 
    integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1"
    crossorigin="anonymous">

  <style>
    .btn {
      padding: 200px 120px;
      font-weight: 700;
      letter-spacing: 1px;
      text-transform: uppercase;
      font-size: 1.7rem;
    }
  </style>
	
</head>

<body>
  <div class="container">
    <div id="display-state-row" class="row text-center">
      <p class="text-center mt-5 mb-0">現在の状態</p><br>
      <h1 id="state" class="text-center mt-4 mb-5">入室</h1>
    </div>
    <div id="ui-buttons" class="d-flex justify-content-around mt-2">
      <div>
        <input type="radio" class="btn-check" name="options-outlined" id="enter" autocomplete="off" checked>
        <label class="btn btn-outline-primary" for="enter">入室</label>
      </div>

      <div>
        <input type="radio" class="btn-check" name="options-outlined" id="leave" autocomplete="off">
        <label class="btn btn-outline-success" for="leave">退室</label>
      </div>
    </div>
  </div>
</body>

<script>
  let socket = io.connect();

  $('#enter').on('click', (e) => {
    $('#state').html('入室');
    socket.emit('state_from_client', 'enter');
  });

  $('#leave').on('click', (e) => {
    $('#state').html('退室');
    socket.emit('state_from_client', 'leave');
  });

</script>
</html>

```




## 運用してみて

実際にこのシステムを運用してみて5ヶ月ほど経つので、いくつかアプデした点と今も問題となっている点について話します。

#### 熱で落ちる問題

熱でラズパイが落ちてしまうことが何度もあったので、ちょっと見苦しいですが研究室にあったファンを置いてマステで固定して運用しています。また、落ちてもcronでプログラム強制再起動するようにしました。
その結果、連続稼働にも耐えています...がまた次の問題が (後述)

![ファン.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/130420/7f94c4a6-ac39-131b-7354-71939be6a5a5.jpeg)

#### 入室 / 退室の反映が重い問題

現在、入室 / 退室の切替フローが以下のようになっています。

1. iPadに表示しているwebページの入室 / 退室をタップ
2. Socket.IOのイベントを発火してサーバに通知
3. サーバがイベントを受け取ったら、状態を記述しているテキストファイルを上書き
4. 学生証をタッチしたらテキストファイルの状態を読んでSlackへPOST

この2→3の部分が重く、切り替えに15分以上かかってしまうことが多いです...
サーバを起動してすぐの間は問題なく動くのですが、しばらくすると重くなります。
SSH接続に関しても同じ症状なので、ラズパイが重くなってしまっているとみなして半ば諦めています😢
(有識者いらっしゃったら是非教えてください🙇‍♂️)

## おわりに

ありがたいことに、研究室メンバーのうち21名に登録して頂き、現在5ヶ月以上運用しています🙏

実際に運用してみて色々と問題はありますが、入室するたびにSlackを開いて「作業します」という手間は省けたのはかなりのメリットだったのではないでしょうか(特に面倒くさがりな私にとって)。
退室時にリアクションをつけていたときとは違い、退室時間を記録できるようになったのもよかったと思います。

タッチした際に何のフィードバックもない状態でまだ不完全なので、「読み取ったら音を鳴らす」などの処理を追加して改善していきたいと思います。