学生証で行う入退室管理システム【部室のロボティクス化】

# はじめに
近畿大学ロボット研究会の部室で、Felica(学生証)を用いた入退室管理システムを作成しました。せっかくロボ研なのでプログラムだけでなくハードウェアとも連携させた価値を生み出したいと思い、入退室管理システムと関連付けて、自動で部室の電気をつけたり消したりできるようにしました。

# 入退室管理システムについて
入退室管理システムの全体像や動作の様子は以下のブログにまとまっています。

https://qiita.com/keigo1110/items/f7273980e7b0bb2dd39c

以下のブログでプログラムをまとめています。

https://qiita.com/keigo1110/items/1ab595b48bd03734069b

# 動作デモ
実際に電気がついたり消えたりする様子です。

<iframe width="523" height="929" src="https://www.youtube.com/embed/XedxYF_UYmQ" title="入退室タッチ" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

スイッチを押す部分はこんな感じになっています。

<iframe width="523" height="929" src="https://www.youtube.com/embed/Fmen-sdTWAI" title="スイッチ押す" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

# スイッチを操作するためのデバイス群
またまた、部室にあるものを拾い集めて作成したので、スマートではありませんが以下のものを用いて電気のスイッチを押す機構を作成しました。

|    |    |
| :----: | :----: |
|マイコン  |  ESP32  |
|　センサ　|Tilt Ball switch|
|アクチュエータ|サーボモーター SG-90|
|アクチュエータ|プッシュソレノイド|

`ESP32`は、Wi-Fi機能もついた安価なマイコンです。`Tilt Ball switch`は、傾きや振動を検知するセンサーで、内部に小さな球があり、物体の傾きや振動によって球が転がることで、電気回路上の接点が開閉します。主に物体の傾きや動きを検出して、それに応じて制御や動作を行うために使用されるので今回用いることにしました。詳しくは下の動画をみてください。

https://www.youtube.com/watch?v=QZWmrRkvHJk

そして、`サーボモータ`を制御して、Tilt Ball switchを傾けさせ電気を流したり止めたりします。`プッシュソレノイド`は、電流を流すと、コイル周りに磁場が発生し、可動コアが引き寄せられます。この力をもちいてスイッチを押します。詳しくは下の動画をみてください。


https://www.youtube.com/watch?v=3axsUr4xIIo

まとめると、入退室管理システムからの信号を`ESP32`のマイコンが受け取り、`サーボモータ`を動かします。`サーボモータ`が動くことで`Tilt Ball switch`が傾きを検知し、電流を流します。電流が流れると`プッシュソレノイド`が動作します。

わざわざ複雑にしているのはサーボモータではスイッチを押すほどの力がなかったからです

全体の構成は以下のようになっています。
![Screenshot 2023-05-31 at 22.00.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3346908/d26c7a90-a972-fe47-284e-63fa1f202565.png)


# 通信の仕方
ソケット通信を用いています。
英語から考えても"受け口"という意味で、通信の出入り口となる受け口を用いて通信するものです。
今回はマイコンをサーバーとして、PCをクライアントとして、PCでカードリーダ読み取りのイベントで必要があればマイコンのサーバーにアクセスするようにしました。


# 全体像
chatGPTにコードをなげてPlantUMLでシーケンス図を作成できるように指示しました。

![Screenshot 2023-05-25 at 2.42.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3346908/0002e0f3-b294-407f-81cb-edd994068735.png)

やり方は、

https://bocek.co.jp/media/formula/development/568/

こちらを参考にしました。


# プログラム
### プログラム全体像
以前ブログにあげたものに付け足すだけなので、プログラム全体は最後に参考として載せておきます。
以前のブログというのは以下のURLから見てください。

https://qiita.com/keigo1110/items/1ab595b48bd03734069b

ここから付け加えたところをピックアップして解説します。

### ESP32ライブラリ準備
ESP32の場合、Servo.hをそのまま使うことができません。

こちらを参考にセットアップしました。

https://wak-tech.com/archives/1534

### ESP32（サーバー側）のプログラム
```cpp
//ESP32-PC間通信用プログラム(ESP32側)
#include <WiFi.h>   // WiFi
#include <Servo.h>  // サーボ

const char* ssid = "roboken1";      // ネットワークのSSID
const char* password = "roboken1";  // ネットワークのパスワード

const IPAddress ip(192, 168, 0, 129);      // 固定IPアドレスの設定
const IPAddress subnet(255, 255, 255, 0);  // サブネットマスクの設定

WiFiServer server(5000);  // ポート番号5000でサーバーとして使用する

Servo myservo;  // Servoオブジェクトを作成

void setup() {
  myservo.attach(13);  // 13番ピンにサーボ制御線（オレンジ）を接続
  Serial.begin(115200);  // ビットレート115200でシリアル通信を開始
  WiFi.begin(ssid, password);  // アクセスポイントに接続
  if (!WiFi.config(ip, ip, subnet)) { //WiFi接続確認されなかったら
    Serial.println("Failed to configure!");
  }

  // 接続が完了したら以下の内容をシリアルモニタに表示
  Serial.println("");
  Serial.println("WiFi connected.");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());  // 自身のIPアドレスを表示

  server.begin();  // サーバー開始
}

int value = 0; //初期化

void loop() {
  WiFiClient client = server.available();  // サーバーに接続され、読み取り可能なデータがあるクライアントを取得

  if (client) {  // クライアントの情報が取得できた場合（クライアントに対しサーバーが開かれている場合）
    if (value == 0) {
      myservo.write(180);  // 180度へ回転
      delay(1000);
      Serial.println("180");
      value = 1;
    } else {
      myservo.write(0);  // 元に戻る
      delay(1000);
      Serial.println("0");
      value = 0;
    }
    client.stop();                // サーバーとの接続を切断
    Serial.println("Client Disconnected."); // 動いたことの確認
  }

  delay(1000);

  if (value == 1) {
    myservo.write(0);  // 元に戻る
    delay(1000);
    value = 0;
  }
}

```
### PC（クライアント側）のプログラム
```py
def light_on_off():
    '''値設定'''
    ip_address = '192.XXX.X.XXX' #サーバー（ESP32のIPアドレス）
    port = 5000 #ポート番号
    buffer_size = 4092 #一度に受け取るデータの大きさを指定

    recieve_message = ""
    recieve_status = True

    #クライアント用インスタンスを生成
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # サーバーに接続を要求する（IPアドレスとポート番号を指定）
    client.connect((ip_address, port))
    time.sleep(1)

    '''ESP操作する'''
    # サーバにデータを送信する
    message = "connect" 
    time.sleep(1)
    message += "\n"#文末に改行コードを追加
    client.sendall(bytes(message, encoding='ASCII'))#文字列をバイトに変換し送信（文字コードはASCIIを使用）

    #サーバーからの応答を確認する用
    #data = client.recv(buffer_size) #サーバから送られてきたデータを読み取り（上限4092ビット）
    #print("サーバからのメッセージ")
    #print(data.decode())#受け取ったデータ（バイト形式）を読める形式にデコードして表示

    #通信を終了
    client.close()
    #print("通信完了")
```

# 製作してみて
電気をつけたり消したりするスイッチが押しにくいところにあるので、面倒さが改善されたのはよかったです。
それ以上に、部室が体の一部のように機能して面白かったです。

# まとめ
やっとロボ研っぽくなってきました。ハードウェアが動き出すとワクワク感が増します。

# 【参考】全体像
```py
import numpy as np
import binascii
import nfc
import time
import requests
from pygame import mixer
import pyttsx3
from gtts import gTTS
import webbrowser
import pyautogui
import discord
import json
import openpyxl
import socket

import inAA
import outAA

'''学生証のサービスコード'''
service_code = 0x1A8B

'''webhookの初期設定'''
url = 'webhook url'

'''エクセルファイル'''
filename = 'club.xlsx'
sheetname = 'Sheet1'

'''起動して一回だけ実行する'''
def setup():
    try:
        discord_notify(url, "起動しました")
    except:
        net_connect()


'''学生証読み取ったときの処理'''
def on_connect_nfc(tag):
    try:
        '''学生証から必要な情報を手に入れるための設定'''
        idm, pmm = tag.polling(system_code=0xfe00)
        tag.idm, tag.pmm, tag.sys = idm, pmm, 0xfe00
    except:
        print('\n読み取れませんでした。もう一度お願いします。\n')
        error_call()
        time.sleep(1)
        main()

    '''学生証の型番だったら'''
    if isinstance(tag, nfc.tag.tt3.Type3Tag):
        try:
            '''学生証から学籍番号を取り出す'''
            sc = nfc.tag.tt3.ServiceCode(service_code >> 6 ,service_code & 0x3f)
            bc = nfc.tag.tt3.BlockCode(0,service=0)
            data = tag.read_without_encryption([sc],[bc])
            sid = int(data[2:12])
            '''エクセル内で検索'''
            student_line = excel_student_column(sid)
            '''ゲストモード'''
            if student_line == '0': #
                guest_name, guest_call_name = guest_in(sid)
                inAA.inAA(guest_name)
                guest_call(guest_call_name, 'よろしくね')
                wb, ws = excel_file_read(filename, sheetname)
                n = sum_column(ws['D'], '1')
                '''discord送信処理'''
                message_o = guest_name + 'が入室しました。\n現在部室に　' + str(n) + '人　います。\nーーーーーーーーーーーーーー'
                discord_notify(url, message_o)

            else:
                '''部員の情報を取得'''
                student_chinese_name = get_info('B', student_line) 
                status = get_info('D', student_line) 

                '''退室状態(0)のとき入室処理を行う'''
                if status == '0':
                    try:
                        '''エクセルデータを更新する'''
                        wb, ws = excel_file_read(filename, sheetname) 
                        overwrite('D', student_line, '1') 
                        now_in = time.time() 
                        str_now_in = str(now_in)
                        overwrite('E', student_line, str_now_in) 
                        n = sum_column(ws['D'], '1') + 1 
                        '''discord送信処理'''
                        message_e = student_chinese_name + 'が入室しました。\n現在部室に　' + str(n) + '人　います。\nーーーーーーーーーーーーーー'
                        discord_notify(url, message_e)
                    except:
                        net_connect()
                        main()

                    '''部室アウトプット処理'''
                    name_call(student_line, "こんにちは。")
                    inAA.inAA(student_chinese_name)

                    if n == 1: #一人目入室したら電気つける
                        light_on_off() #電気のスイッチ押す関数

                else:
                    try:
                        '''エクセルデータを更新する'''
                        wb, ws = excel_file_read(filename, sheetname)
                        overwrite('D', student_line, '0')
                        if_data = get_info("B", student_line)

                        if if_data != 'ゲスト' + str(sid):
                            '''時間計算'''
                            now_out = time.time()
                            str_now_out = str(now_out)
                            past = get_info('E', student_line)
                            active_time = float(now_out) - float(past)
                            overwrite('E', student_line, str_now_out)
                            sum_time = get_info('F', student_line)
                            new_sum_time = float(sum_time) + float(active_time)
                            overwrite('F', student_line, new_sum_time)
                        n = sum_column(ws['D'], '1') - 1
                        '''discord送信処理'''
                        message_o = student_chinese_name + 'が退室しました。\n現在部室に　' + str(n) + '人　います。\nーーーーーーーーーーーーーー'
                        discord_notify(url, message_o)
                    except:
                        net_connect()
                        main()

                    '''活動時間の表示'''
                    if if_data != 'ゲスト' + str(sid):
                        day1, hour1, minutes1, second1 = cal_time(active_time)
                        day2, hour2, minutes2, second2 = cal_time(new_sum_time)
                        print('今回の活動時間は' + str(day1) +'日' + str(hour1) + '時間' + str(minutes1) + '分' + str(second1) + '秒です。')
                        print('これまでの活動時間は' + str(day2) +'日' + str(hour2) + '時間' + str(minutes2) + '分' + str(second2) + '秒です。')
                    else :
                        hour1 = 25

                    '''部室アウトプット処理'''
                    outAA.outAA(student_chinese_name)

                    if hour1 == 0:
                        arere_call()
                    elif hour1 == 25:
                        guest_call('ゲスト', '。また来てね')
                    else:
                        name_call(student_line, "お疲れさまです。")

                    '''ゲストデータの削除'''
                    if if_data == 'ゲスト' + str(sid):
                        row_clear(student_line)

                    if n == 0: #ゼロ人になったら電気消す
                        light_on_off() #電気のスイッチ押す関数

        except Exception as e:
            error_call()
            print("error: %s" % e)

    else:
        error_call()
        print("error: tag isn't Type3Tag")


'''時間の計算'''
def cal_time(specific_time):
    day = int(specific_time // 86400)
    time_left = specific_time - 86400 * day
    hour = int(time_left // 3600)
    time_left2 = time_left - 3600 * hour
    minutes = int(time_left2) // 60
    time_left3 = time_left2 - 60 * minutes
    second = int(time_left3 // 1)
    return day, hour, minutes, second


'''音関係'''
def name_call(line, word):
    speak = get_info("C", line)
    call_word = speak +"さん" + word
    tts = gTTS(text= call_word, lang = "ja")
    tts.save('voice.mp3')
    mp3('voice.mp3')

def guest_call(name, word):
    call_word = name +"さん" + word
    tts = gTTS(text= call_word, lang = "ja")
    tts.save('guest.mp3')
    mp3('guest.mp3')

def error_call():
    mp3('piropiro.mp3')

def arere_call():
    mp3('arere.mp3')

def mp3(file_name):
    mixer.init()
    mixer.music.load(file_name)
    mixer.music.play()
    time.sleep(2)

'''discordの処理'''
def discord_notify(url, message):
    """
    Discord send message.

    Args:
        url (str): discord webhook url.
        message (str): message.
    """
    if url:
        requests.post(url,data={'content': message})

'''エクセル関数'''
def excel_file_read(file_name, sheet_name):
    filename = file_name
    wb = openpyxl.load_workbook(filename)
    ws = wb[sheet_name]
    return wb, ws

def row_clear(line):
    wb, ws = excel_file_read(filename, sheetname)
    int_line = int(line)
    ws.delete_rows(int_line)
    wb.save("club.xlsx")

def overwrite(column, line, inout):
    wb, ws = excel_file_read(filename, sheetname)
    '''シート内のA1セルに文字列を入力'''
    cell = column + line
    ws[cell] = inout
    wb.save("club.xlsx")

def sum_column(column, keyword):
    wb, ws = excel_file_read(filename, sheetname)
    result = 0
    for cell in column:
        try:
            value = str(cell.value)
        except:
            continue
        if value == keyword:
            result += 1
    return result

def get_info(specific_column, line):
    wb, ws = excel_file_read(filename, sheetname)
    student_information = specific_column + line
    info = ws[student_information].value
    info_str = str(info)
    return info_str

def guest_in(sid):
    wb, ws = excel_file_read(filename, sheetname)
    '''シート内のA列セルに文字列を入力'''
    str_sid = str(sid)
    do_check = 0
    for row in range(101, 200):
        if do_check == 1:
            break
        str_row = str(row)
        if ws['A' + str_row].value == None:
            ws['A' + str_row] = str_sid
            ws['B' + str_row] = 'ゲスト' + str_sid
            ws['C' + str_row] = 'ゲスト' + str_sid[0:2] + 'の' + str_sid[7:10] + '番'
            ws['D' + str_row] = '1'
            do_check = 1
    wb.save("club.xlsx")
    '''エクセルに書き込んだのはすぐ使えないから処理用に準備'''
    make_name = 'ゲスト' + str_sid
    make_call_name = 'ゲスト' + str_sid[0:2] + 'の' + str_sid[7:10] + '番'
    return make_name, make_call_name

def excel_student_column(sid):
    wb, ws = excel_file_read(filename, sheetname)
    '''学籍番号の列から何行目にあるのか検索する'''
    str_sid = str(sid)
    result = search_column(ws['A'], str_sid)
    if result == 0:
        str_result = '00'
    else:
        str_result = str(result)
    student_num = str_result[1:]
    return student_num
def search_column(column, keyword):
    result = 0
    for cell in column:
        try:
            value = str(cell.value)
        except:
            continue
        if value == keyword:
            cell_address = openpyxl.utils.get_column_letter(cell.column) +  str(cell.row)
            result = cell_address
    return result


'''自動で電気つけたり消したりする'''
def light_on_off():
    '''値設定'''
    ip_address = '192.168.0.129' #サーバー（ESP32のIPアドレス）
    port = 5000 #ポート番号
    buffer_size = 4092 #一度に受け取るデータの大きさを指定

    recieve_message = ""
    recieve_status = True

    #クライアント用インスタンスを生成
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    # サーバーに接続を要求する（IPアドレスとポート番号を指定）
    client.connect((ip_address, port))
    time.sleep(1)

    '''ESP操作する'''
    # サーバにデータを送信する
    message = "connect" #input("送信するメッセージを入力してください\n→")
    time.sleep(1)
    message += "\n"#文末に改行コードを追加
    client.sendall(bytes(message, encoding='ASCII'))#文字列をバイトに変換し送信（文字コードはASCIIを使用）
    #print("サーバーへデータ送信")

    #サーバーからの応答を受信
    #data = client.recv(buffer_size) #サーバから送られてきたデータを読み取り（上限4092ビット）
    #print("サーバからのメッセージ")
    #print(data.decode())#受け取ったデータ（バイト形式）を読める形式にデコードして表示
    #通信を終了
    client.close()
    print("通信完了")

'''インターネット接続処理'''
def net_connect():
    webbrowser.open("https://ninsho-nw1.kudos.kindai.ac.jp:8000/")

    time.sleep(5)

    pyautogui.press("tab")
    time.sleep(1)
    pyautogui.write('2010350214h')

    time.sleep(1)

    pyautogui.press("tab")
    time.sleep(1)
    pyautogui.write('2001')
    time.sleep(1)
    pyautogui.write('1110')

    time.sleep(1)

    pyautogui.press("tab")
    pyautogui.press("enter")
    time.sleep(5)

    x=250
    y=10
    pyautogui.click(x, y)


'''メイン処理'''
def main():
    clf = nfc.ContactlessFrontend('usb')
    while True:
        clf.connect(rdwr={'on-connect': on_connect_nfc})

if __name__ == "__main__":
    setup()
    main()
```