IoT Device : RaspberryPiを使ったFeliCa利用のタイムカードシステム


# Internet of Things：Device側のPythonを記述する

## 行動をタイムスタンプするシステム（行動と時間の紐づけ）

### 仕様
* 言語はPython 2.7を使用する  
* ハードウェアはRaspberryPiを使用する

|処               理|
|:----------------------------------------------------------------------------------:|
|正確な時を刻むこと（NTP利用）|
|FeliCaのIDmを読み取ること|
|確認と管理用に本人と上司にメールを送信すること|
|クラウド上のデータベースアクセスが可能であること|
|マルチスレッド（FeliCa読み取り、メール送信、LCD表示）環境で動作すること|
|セキュリティ（データの揮発性）が担保されること|

### 使用基板の外観
![timecard5.JPG](https://qiita-image-store.s3.amazonaws.com/0/121947/ac622308-d7e4-3fdd-2a4e-b47be179026c.jpeg)
---
## コンテキストダイヤグラム
* USBインターフェースに接続されたFeliCaカードリーダー、GPIO（汎用入出力）に接続されたブザー、i2cインターフェースに接続された８桁２行の小さな液晶ディスプレイ、有線または無線のネットワークで構成されています。

* タイムカードをFeliCaカードリーダにタッチすると登録されたIDであればブザーを鳴らし、クラウド上のデータベースに時刻と社員コードを送信します。データベースに記録されると再度ブザーを鳴らし、タッチされた時刻を電子メールで本人と上司に送信します。

* 正確な時間を担保するためにNTPを利用しています

![timecard0.png](https://qiita-image-store.s3.amazonaws.com/0/121947/ffce6263-8d59-96e7-41bc-73801b64b9ed.png)

### デバイスの外観

![timecard6.JPG](https://qiita-image-store.s3.amazonaws.com/0/121947/e8f83c42-da08-f5ce-a3de-29b14f4d052c.jpeg)

---
## ０．TimeCardSystemのスレッド
メインスレッドは２と３の2本のスレッドを立ち上げタイムカードがタッチされた時にメッセージによってそれらのスレッドを駆動する。  
メインスレッドを含む全てのスレッドは常時ブロックされているが、２のLCD表示スレッドは時刻を表示するためにメッセージQUEに書き込みがない場合は200ms毎に例外が発生し時刻の更新（システム秒の歩進）を確認している。  

１のタイムカード読取りルーチンはメインスレッドから起動され以下の一連の動作を行います。  

* カードから読み取ったIDｍ（製造者ID）から社員リストを検索し社員コードを２のLCD表示スレッドにメッセージを送信する。
* 同時に社員コード以外に社員名、メールアドレス、上司のメールアドレスを取り出し、読取時刻と共に３のMail送信スレッドに  
メッセージを送信する。
* 社員コードと読取時刻をクラウド上のデータベースに送信する。

![timecard1.png](https://qiita-image-store.s3.amazonaws.com/0/121947/47bbb9d3-39a9-05ae-f486-3f209aeff39d.png)

## １．FeliCaカードを読取る

* 起動時にクラウド上のデータベースから社員リストを読み取る  
* ローカルデータベース(sqlite3)に社員リストを登録する  
* カードがタッチされるとIDmを読取りconnectedが起動する  
* 表示とメールのメッセージを送信し、クラウド上のデータベースに書き込みを行う

![timecard4.png](https://qiita-image-store.s3.amazonaws.com/0/121947/23995cf6-2af9-92af-c35b-c9374473b56e.png)

## ２．LCDに時刻を表示する
通常は上段に年月日、下段に時分秒を表示している（１秒毎に表示を更新する）。  
表示メッセージを受信した時に社員コードを１秒間表示する。

* システム時刻はNTPで補正され正しい時刻を刻みます。
* ２００ｍｓ毎に例外が発生し秒が更新しているかを確認します。秒が歩進したときに表示を更新します。
* また、一時間毎にデータベース接続確認を行います。（コネクションが切断されることを防止するため）

|イベント|動作|
|:-----:|:--------------:|
|メッセージ受信|社員コードを１秒間表示|
|タイムアウト(200ms)|時刻を更新|
|タイムアウト(1hour)|データベース確認|

![timecard2.png](https://qiita-image-store.s3.amazonaws.com/0/121947/d3ca36f1-8f30-66a5-b2cd-4d9f1e16ab63.png)

## ３．Mailを送信する
メッセージを受信しメールを送信する

![timecard3.png](https://qiita-image-store.s3.amazonaws.com/0/121947/388a4b5d-44ad-1dda-b149-a5b33019f6eb.png)

### 送信するメールのヘッダと本文
標題に社員名、本文に時刻を記述している

```
MIME-Version: 1.0
Content-Type: text/plain; charset="iso-2022-jp"
Content-Transfer-Encoding: 7bit  
Subject: 小林万壽夫  
From: mex.timecard.system@gmail.com  
To: kobayashi@xxxxxxx.co.jp  
Cc: boss@xxxxxxx.co.jp  
Date: Tue, 02 May 2017 08:50:27 -0000  
  
2017-05-02 17:50:26
```


## ４．インポートライブラリ


```python
import Queue
import threading
import time
import datetime
import smbus
import nfc
import binascii
import RPi.GPIO as GPIO
import sqlite3
import sys
import MySQLdb
import smtplib
from email.MIMEText import MIMEText
from email.Header import Header
from email.Utils import formatdate
import logging
import logging.config
from logging import FileHandler, Formatter
import socket
```

## ５．スレッドの使い方


```python
# LCD表示用のスレッドを作成
    queue = Queue.Queue()
    t = ThreadLcd(queue)
    t.setDaemon(True)
    t.start()
# 電子メール送信用のスレッドを作成
    queue1 = Queue.Queue()
    m = ThreadMail(queue1)
    m.setDaemon(True)
    m.start()
```

### 5.1 LCD表示


```python
class ThreadLcd(threading.Thread):
  """Threaded Lcd Grab"""
  def __init__(self, queue):
    threading.Thread.__init__(self,name='Lcd')
    self.queue = queue
    now = datetime.datetime.now()
    self.sec = now.second
    self.hour = now.hour
    lcd.setaddress(0, 0)                          # 上段（現在の年月日を表示）
    lcd.puts(now.strftime("%y-%m-%d"))
    lcd.setaddress(1, 0)                          # 下段（現在の時分秒を表示）
    lcd.puts(now.strftime("%H:%M:%S"))

  def run(self):
    while True:
      try:
        #grabs host from queue
        host = self.queue.get(True,0.2)  # ０．２秒間メッセージを受信できなかった場合、割り込みが発生する
        lcd.clear()
        lcd.setaddress(0, 0)                      # １６文字のメッセージを８文字に分解し上下段に表示する
        lcd.puts(host[:8])
        lcd.setaddress(1, 0)
        lcd.puts(host[8:])
        time.sleep(1)
        #signals to queue job is done
        self.queue.task_done()
      except:
        now = datetime.datetime.now()
        if self.sec != now.second:             # 秒更新
          self.sec = now.second
          lcd.setaddress(0, 0)                   # 上段
          lcd.puts(now.strftime("%y-%m-%d"))
          lcd.setaddress(1, 0)                   # 下段
          lcd.puts(now.strftime("%H:%M:%S"))
          if self.hour != now.hour:              # 時更新
            self.hour = now.hour
            ping_connection()                    # SQL keep alive
            
def ping_connection():
# MySQL WDT
  cur1 = db.cursor() 
  try:
    cur1.execute("SELECT 1")
  except Exception as ex:
    logger.error(ex)
  cur1.close()

```

### 5.2 電子メールを送信


```python
class ThreadMail(threading.Thread):
  """Threaded Mail Grab"""
  def __init__(self, queue):
    threading.Thread.__init__(self,name='Mail')
    self.queue = queue

  def run(self):
    while True:
      try:
        #grabs host from queue
        r = self.queue.get(True)
        from_addr = 'mex.timecard.system@gmail.com'
        to_addr = r[2]
        cc_addr = r[3]
        bcc_addr = 'mex.timecard.system@gmail.com'
        msg = create_message2(from_addr, to_addr, cc_addr, r[1], r[5], 'ISO-2022-JP')
        send_via_gmail(from_addr, to_addr, cc_addr, bcc_addr, msg)
        #signals to queue job is done
        self.queue.task_done()
      except Exception as ex:
        logger.error(ex)

def create_message2(from_addr, to_addr, cc_addr, subject, body, encoding):
    # 'text/plain; charset="encoding"'というMIME文書を作ります
    msg = MIMEText(body, 'plain', encoding)
    msg['Subject'] = Header(subject, encoding)
    msg['From'] = from_addr
    msg['To'] = to_addr
    msg['CC'] = cc_addr
    msg['Date'] = formatdate()
    return msg

def send_via_gmail(from_addr, to_addr, cc_addr, bcc_addr, msg):
    s = smtplib.SMTP('smtp.gmail.com',587)
    s.ehlo()
    s.starttls()
    s.ehlo()
    s.login('mex.timecard.system', 'xxxxxxxxxx')
    s.sendmail(from_addr, [to_addr,cc_addr,bcc_addr], msg.as_string())
    s.quit()

```

## 6. データベース

### 6.1 オンメモリーで動作する軽量データベース sqlite3にスキーマ（構造）を設定する


```python
    try:
      lcd.setaddress(0, 0)
      lcd.puts('Starting')
      lcd.setaddress(1, 0)
      lcd.puts('Sqlite3 ')
      con = sqlite3.connect(':memory:', isolation_level=None)
      with open('names.sql', 'rt') as f:
        schema = f.read()
      con.executescript(schema)
      #print schema
      time.sleep(1)
    except:
      show_reboot()
      quit()
```

#### スキーマの内容[names.sql]


```python
PRAGMA encoding="UTF-8";
DROP TABLE IF EXISTS `names`;
CREATE TABLE `names` (
  `社員コード` integer,
  `社員名` text,
  `社員メールアドレス` text,
  `上司メールアドレス` text,
  `社員カードID` text PRIMARY KEY
);
```

### 6.2 Google Cloud Platformで動作する CloudSQL (MySQL)


```python
    try:
      lcd.setaddress(0, 0)
      lcd.puts('Starting')
      lcd.setaddress(1, 0)
      lcd.puts('MySQLdb ')
      db = MySQLdb.connect(host="xxx.xxx.xxx.xxx", # your host, usually localhost
                     user="timecard", # your username
                      passwd="xxxxxx", # your password
                      db="timecard",
                      charset="utf8") # name of the data base
      cur = db.cursor() 
      time.sleep(1)
    except:
      show_reboot()
      quit()
```

#### データベース(timecard)のテーブル定義ファイル(names, timestamp)


```python
CREATE DATABASE  IF NOT EXISTS `timecard` /*!40100 DEFAULT CHARACTER SET utf8 */;
USE `timecard`;
-- MySQL dump 10.13  Distrib 5.6.17, for Win32 (x86)
--
-- Host: Server-5    Database: timecard
-- ------------------------------------------------------
-- Server version	5.6.20

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;

--
-- Table structure for table `names`
--

DROP TABLE IF EXISTS `names`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `names` (
  `社員コード` int(6) unsigned zerofill NOT NULL COMMENT '社員は000xxx、パートさんは900xxx',
  `社員名` varchar(45) NOT NULL,
  `社員メールアドレス` varchar(45) DEFAULT NULL,
  `上司メールアドレス` varchar(45) DEFAULT NULL,
  `社員カードID` varchar(16) NOT NULL,
  PRIMARY KEY (`社員コード`),
  UNIQUE KEY `社員コード_UNIQUE` (`社員コード`),
  UNIQUE KEY `社員名_UNIQUE` (`社員名`),
  UNIQUE KEY `IDm_UNIQUE` (`社員カードID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `names`
--

LOCK TABLES `names` WRITE;
/*!40000 ALTER TABLE `names` DISABLE KEYS */;
INSERT INTO `names` VALUES (000149,'小林万寿夫','kobayashi@xxxxxxx.co.jp','boss@xxxxxxxx.co.jp','012e34e73001xxxx');
INSERT INTO `names` VALUES (999999,'予備１（伊丹）','','','0115e500f30axxxx');
/*!40000 ALTER TABLE `names` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

-- Dump completed on 2014-09-20 17:33:41
```


```python
CREATE DATABASE  IF NOT EXISTS `timecard` /*!40100 DEFAULT CHARACTER SET utf8 */;
USE `timecard`;
-- MySQL dump 10.13  Distrib 5.6.17, for Win32 (x86)
--
-- Host: Server-5    Database: timecard
-- ------------------------------------------------------
-- Server version	5.6.20

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;

--
-- Table structure for table `timestamp`
--

DROP TABLE IF EXISTS `timestamp`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `timestamp` (
  `idtimestamp` int(11) NOT NULL AUTO_INCREMENT,
  `社員コード` int(6) unsigned zerofill NOT NULL,
  `出退勤時刻` varchar(45) NOT NULL,
  PRIMARY KEY (`idtimestamp`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='出退勤の時刻を記録';
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `timestamp`
--

LOCK TABLES `timestamp` WRITE;
/*!40000 ALTER TABLE `timestamp` DISABLE KEYS */;
/*!40000 ALTER TABLE `timestamp` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

-- Dump completed on 2014-09-20 17:33:41
```

### 6.3 起動時にCloud SQLからsqlite3にデータをコピーする


```python
 # 0 :社員コード
 # 1 :社員名
 # 2 :社員メールアドレス
 # 3 :上司メールアドレス
 # 4 :社員カードID
 try:
    cur.execute("SELECT * FROM names")
  except Exception as ex:
    logger.error(ex)
  for row in cur.fetchall() :
    sql = u"insert into `names` values(" + str(row[0]) + ",'" + row[1] + "','" + row[2] + "','" + row[3] + "','" + row[4] + "')"
    try:
      con.execute(sql)
    except sqlite3.Error as e:
      logger.error(e)
```

## 7. FeliCaカードリーダ


```python
  # タッチ時のハンドラを設定して待機する
  clf = nfc.ContactlessFrontend('usb')

  while True:
    try:
      clf.connect(rdwr={'on-connect': connected,'targets': [nfc.clf.TTF(br=212,idm=None,pmm=None,sys=None)]})
    except:
      logger.info('FeliCa connect')
```


```python
def connected(tag):
    # タグのIDを確認する
    if isinstance(tag, nfc.tag.tt3.Type3Tag):
        try:
            # 登録されたIDｍであることを確認
            sql = u"SELECT * FROM names WHERE `社員カードID`='" + binascii.hexlify(tag.idm) + "'"
            c = con.execute(sql)
            r = c.fetchone()
            if r:                                             # 登録済
              GPIO.output(IO_NO, True)                                               # ブザーを鳴動（ピィ）
              time.sleep(0.1)
              GPIO.output(IO_NO, False)
              r = r + (datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),)
              sql = u"insert into `timestamp` (`社員コード`,`出退勤時刻`) values(" + str(r[0]) + ",'" + r[5] + "')"
              queue.put(str(r[0]).zfill(6))　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　# LCD表示スレッドに社員コードを送信
              cur.execute(sql)                                                                # クラウド上のデータベースに書き込む
              db.commit()                                                                       # クラウド上のデータベースをコミットする
              GPIO.output(IO_NO, True)                                               # ブザーを鳴動（ピィ）
              time.sleep(0.1)
              GPIO.output(IO_NO, False)
              queue1.put(r)                                                                    # メール送信スレッドにデータを送信する
            else:                                         # 未登録
              logger.info('Touch: %s' % binascii.hexlify(tag.idm))    # 未登録カードをロギング
              for i in range(3):                                                                # ブザーを３回鳴動（ピィ・ピィ・ピィ）
                GPIO.output(IO_NO, True)
                time.sleep(0.1)
                GPIO.output(IO_NO, False)
                time.sleep(0.1)

        except Exception as e:
            logger.error(e)
    else:                                               # Type3タグではない場合
        logger.info('Touch: Not a Type3Tag')
        GPIO.output(IO_NO, True)                                                     # ブザーを１秒間鳴動（ピィー）
        time.sleep(1)
        GPIO.output(IO_NO, False)

    while tag.is_present:                                                                   # カードが離れるのを待つ
        time.sleep(0.5)
```

## 8. その他（起動時の引数、初期化、IPアドレスの表示）


```python
if __name__ == "__main__":
    argvs = sys.argv　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　# 起動時の引数設定（ここでは事業所）
    argc = len(argvs)
    if (argc < 2):
        print 'Usage: # python %s [IJ AJ OJ TE]' % argvs[0]
        quit()
    if (argc == 2 and (argvs[1] == 'IJ' or argvs[1] == 'AJ' or argvs[1] == 'OJ' or argvs[1] == 'TE')):
        pass
    else:
        print 'Usage: # python %s [IJ AJ OJ TE]' % argvs[0]
        quit()

    logging.config.fileConfig('config.ini')                   # ロギングの初期化
    logger = logging.getLogger(__name__)

    logger.info('System Start (%s)' % argvs[1])
    
    start = time.time()                                                  # 起動時刻の取得
    
    GPIO.setmode(GPIO.BCM)                                      # 汎用入出力(GPIO)の初期化
    GPIO.setup(IO_NO, GPIO.OUT)
    
    lcd = i2clcd()                                                           # LCDの初期化
    lcd.clear()                                                                #　LCDのクリア

    lcd.setaddress(0, 0)                                               # インターネットへの接続とローカルIPアドレスの表示
    lcd.puts("WAITING")
    lcd.setaddress(1, 0)
    lcd.puts(".       ")

    i = 0
    while True:
      s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
      try:
        s.connect(("google.com",80))
      except:
        pass
      localIP = (s.getsockname()[0])
      s.close()
      if localIP != "0.0.0.0":
        break
      time.sleep(1)
      i = i + 1
      if i == 8:
        i = 0
        lcd.setaddress(1, 0)
        lcd.puts(".       ")
      else:
        lcd.setaddress(1, i)
        lcd.puts(".")

    lcd.clear()                                                                #　LCDのクリア
    lcd.setaddress(0, 0)                                               # ローカルIPアドレスの表示（例：192.168.0.130）
    lcd.puts(localIP[:8])
    lcd.setaddress(1, 0)
    lcd.puts(localIP[8:])
    time.sleep(5)
```