NanoPi NEO2とICカードリーダーでタイムレコーダーを作る(実用化編)

NanoPi NEO2とICカードリーダーでタイムレコーダーを作る(実用化編)

前回はNanoPi NEO2用タイムレコーダーの超雛形を作ったが、今回はもう少しだけ実用的な雛形にする。

やること

  • カードの記録をデータベースに格納する
  • ユーザーをデータベースで管理する
  • カード情報をデータベースで管理する
  • カードを読むとユーザー名を読み上げる(音声合成)
  • 出勤と退勤を読み上げる
  • 本日退勤済みの後に再出勤しようとしたら警告する(弾かない)
  • 出勤日時が前日の場合は警告する(弾かない)
  • 前回のカード読み込みから30秒以内に同じカード読み込みで前回と今回を取り消し

やらないこと

  • 管理ツール作成
  • Felica以外のカードへの対応

当然巨大化するのでブログの1ページでやるようなことじゃない。

MySQLのデータベースとユーザーの作成

$ mysql -u root -pパスワード mysql

mysql> create database timerecorder;
mysql> GRANT ALL PRIVILEGES ON timerecorder.* TO timemgr@localhost identified by 'パスワード';
mysql> FLUSH PRIVILEGES;
mysql> quit;

1行目: MySQLの管理者アカウントでmysqlデータベースに接続
3行目: timerecorderデータベース作成
4行目: localhostでのみ活動可能なtimemgrというユーザーにtimerecorderデータベースの全テーブル対して全権を与える(兼ユーザー作成)
5行目: 2行目で登録した権限を反映
6行目: mysqlデータベースを抜ける

table.sql
CREATE TABLE timelog (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
`nid` varchar(16) NOT NULL,
`dtime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`finout` bit(1) NOT NULL DEFAULT 0,
`flag` bit(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id)
) ENGINE=InnoDB;

CREATE TABLE card (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`nid` varchar(16) NOT NULL,
`uid` int(10) NOT NULL,
`stdate` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`endate` datetime NOT NULL DEFAULT '2199-12-31 23:59:59',
`flag` bit(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id)
) ENGINE=InnoDB;

CREATE TABLE user (
`id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`uname` varchar(32) NOT NULL,
`uruby` varchar(32) NOT NULL,
`stdate` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`endate` datetime NOT NULL DEFAULT '2199-12-31 23:59:59',
`flag` bit(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE junk (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
`nid` varchar(16) NOT NULL,
`dtime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB;

timelogテーブルは利用者がカードリーダーにカードをかざしたときに記録するためのテーブル。junkテーブルも同様だが、timelogテーブルは登録されたカードがかざされた場合に記録され、未登録のカードがかざされた場合はjunkテーブルに入る。
4つのテーブル共にidカラムはオートインクリメントなので基本触らない。
timelog.nid は登録済みのカードのNFCID2が記録される。
timelog.dtime は登録済みのカードがかざされた時間が記録される。
timelog.finout は出勤(1)or退勤(0)が記録される。
timelog.flag は記録として有効(1)か無効(0)かのフラグ。
card.nid はカードのNFCID2が記録される。
card.uid はカード所有者のID(user.id)が記録される。
card.stdate はカードの有効期間の開始日時が記録される。管理用(このページでは未使用)
card.endate はカードの有効期間の終了日時が記録される。管理用(このページでは未使用)
card.flag はカードが有効(1)か無効(0)かのフラグ。管理用
user.uname はカードの利用者(所有者)名が漢字等で記録される。
user.uruby はカードの利用者(所有者)名のふりがなが記録される。
user.stdate は利用者(所有者)の有効期間の開始日時が記録される。管理用(このページでは未使用)
user.endate は利用者(所有者)の有効期間の終了日時が記録される。管理用(このページでは未使用)
user.flag は利用者(所有者)が有効(1)か無効(0)かのフラグ。管理用
junk.nid は未登録のカードのNFCID2が記録される。
junk.dtime は未登録のカードがかざされた時間が記録される。

$ mysql -u timemrg -pパスワード timerecorder < table.sql   ←テーブル作成

$ mysql -u timemrg -pパスワード timerecorder   ←timerecorderデータベースに入る

mysql> insert into user (uname,uruby) values('山田太郎','やまだたろう');  ←利用者登録
mysql> insert into card (nid) values('0000000000000000');     ←カード登録 (NFCID2を登録)
mysql> quit;

ユーザーを登録してからカード登録。
cardテーブルのuid(ユーザーID)をNOT NULLにしているのでユーザー無しのカード登録はできない(という前提)、基本的にはユーザーを登録してからカードを登録するという流れになる。もちろんダミーのユーザーIDを登録するなら順序は関係ない。
管理ツールを作成して会社で購入したカードを登録しておくなら最初に全てのカードを管理者ユーザーの所有としてフラグで殺しておいて別ユーザーに割り当て変えをしてフラグを有効化するということになるかと。

ウェブサーバー側(PHPとMySQLが入っていること)

db_recorder.php
<?php
header( 'Expires: Fri, 1 Jab 2010 00:00:00 GMT' );
header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s' ) . ' GMT' );
header( 'Cache-Control: no-store, no-cache, must-revalidate' );
header( 'Cache-Control: post-check=0, pre-check=0', false );
header( 'Pragma: no-cache' );

$json_string = file_get_contents('php://input');
$data = json_decode($json_string);
if (json_last_error() != JSON_ERROR_NONE){
    echo "不正です";
    exit;
}
$nid = $data->nid;

$dsn = 'mysql:dbname=timerecorder;host=localhost;charset=utf8mb4';
$user = 'timemgr';         //MySQL用timemgrアカウント
$password = 'パスワード';  //MySQL用timemgrアカウントのパスワード
$conn = new PDO($dsn, $user, $password);
//$conn->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$sql = "select * from card 
            where nid = '" . $nid . "' 
                and stdate < NOW() 
                and endate > NOW() 
                and flag = 1;";

$stmt = $conn->query($sql);
$results = $stmt->fetchall();
if (empty($results)){
    $sql = "insert into junk 
                (nid) 
                value 
                ('" . $nid . "');";

    $conn->exec($sql);
    $msg = "みとうろくカードです";
} else {
    foreach ($results as $row) {
    $uid = $row['uid'];
    }
    $sql = "SELECT * FROM user 
                where id = '" . $uid . "' 
                    and stdate < NOW() 
                    and endate > NOW() 
                    and flag = 1;";

    $stmta = $conn->query($sql);
    $resultsa = $stmta->fetchall();
    if (empty($resultsa)){
        $msg = "ユーザーみとうろく";
    } else {
        //以下有効カードの場合の処理
        foreach ($resultsa as $rowa) {
            $uname = $rowa['uname'];
            $uruby = $rowa['uruby'];
        }
        //タイムログ処理
        //nidの最終記録を確認
        $sql = "select * from timelog 
                    where nid = '" . $nid . "' 
                        and dtime < NOW() 
                        and flag = 1 
                    order by dtime desc limit 1;";

        $stmta = $conn->query($sql);
        $resultsa = $stmta->fetchall();
        if (empty($resultsa)){
        $finout = 1; //記録が無ければ出勤=1から
            $dtime = '2000/01/01 00:00:00';
            $msg = $uruby . "さん、しゅっきん。";
        } else {
            foreach ($resultsa as $rowa) {
                $lid = $rowa['id'];
                $dtime = $rowa['dtime'];
                $finout = $rowa['finout']; //bitカラムの値の扱い要注意
            }
            if($finout == 1){ //直前の有効レコードが出勤
                $finout = 0;
                //前回の記録が出勤 日付をまたいでいたら警告
                if (mktime(0,0,0) > strtotime($dtime)){
                    $msg = $uruby . "さん、たいきん。しゅっきんがきのうです。かくにんしてください。";
                } else {
                    $msg = $uruby . "さん、たいきん";
                }
            } else { //直前の有効レコードが退勤
                $finout = 1;
                //前回の記録が退勤 それが本日なら警告
                if (strtotime($dtime) > strtotime(date("Y/m/d 00:00:00"))){
                    $msg = $uruby . "さん、しゅっきん。ぜんかいのたいきんがほんじつです。かくにんして下さい。";
                } else {
                    $msg = $uruby . "さん、しゅっきん。";
                }
            }
        }
        //最終記録が30秒以内の場合は最終を取り消し
        // さらに今回分も無効レコードとして登録(とにかく記録はする)
        if ((time() - strtotime($dtime)) < 30 ){
            $sql = "update timelog 
                        set flag = 0 
                            where id = $lid;";

            $conn->exec($sql);
            $sql = "insert into timelog 
                        (nid, finout, flag) 
                        value 
                        ('" . $nid . "', " . $finout . ", 0);";

            $conn->exec($sql);
            if ($finout = 0){
                $sfinout = "しゅっきん";
            } else {
                $sfinout = "たいきん";
            }
            $msg = $uruby. "さん、30秒いないの" . $sfinout . "、とりけし";
        } else {
            //記録を付ける
            $sql = "insert into timelog 
                        (nid, finout) 
                        value 
                        ('" . $nid . "', " . $finout . ");";

            $conn->exec($sql); 
        }
    }
}
unset($conn);
echo $msg;
?>

PHPでMySQLの操作はPDOを使用。
仕組みだけなのでエラー制御は無し。スマートさより何したいか解るように書いたのでトリッキーなコードも無し。凄い単純なのでネットワークの不通やデータベースとの接続失敗以外は殆どエラーらしいエラーが発生しないと思う。
NanoPiのOpen JTalkに読み上げさせるのでNanoPi側に返すメッセージ($msg)は基本ひらがなとする。ひらがな綴りを上手く読めない場合は逆にそこだけ漢字にする。

NanoPi NEO2側

speaking_card_recorder.sh
#!/bin/sh
HOSTPATH=example.com/timerecorder/db_recorder.php
LOGFILE=/var/log/card.log
TMP=/tmp/tmp.wav

echo none > /sys/class/leds/nanopi:blue:status/trigger

while :
do
    nid=$(nfc-poll | grep NFCID2 | cut -d: -f 2 | sed 's/ //g')

    if ! [ x"$nid" = x ];then
        echo `date '+%Y/%m/%d %H:%M:%S'` $nid >>$LOGFILE
        RESP=`curl http://$HOSTPATH -s -X POST -H "Content-Type: application/json" -d '{"nid": "'$nid'"}' 2&>/dev/null`
        echo heartbeat > /sys/class/leds/nanopi:blue:status/trigger
        echo $RESP
        echo $RESP | open_jtalk -m /usr/share/hts-voice/mei/mei_normal.htsvoice -x /var/lib/mecab/dic/open-jtalk/naist-jdic -ow $TMP && aplay --quiet $TMP
        rm -f $TMP
        echo none > /sys/class/leds/nanopi:blue:status/trigger
        sleep 1
    fi
done

NanoPi NEO2側は基本的には前回から変わらない。サーバーから返された文字列をOpen JTalkで読み上げる処理が増えただけ。
OpenJTalkでの読み上げについてはNanoPi NEOがTwitterを声でツブヤクンデスを参照。このページにあるMei(女性声)まで入れる。

前回はカード読み取り後2秒は次のカードを読まないようにしていたが、今回はOpen JTalkが喋る間は次のカードを読み取らないようにしている。
読み上げバッチを裏で動かす方法も考えたが、読み上げ中に次の読み上げが発生するのが嫌だった。その分、次の利用者がカード読み込みを待たされることにはなるけど。

サンプル


「山田太郎さん、出勤」
普通に朝に出勤してきてカードをカードリーダーにかざすとこのようになる筈。


「田中一郎さん、退勤」
普通に(朝の)出勤の次にカードをカードリーダーにかざすとこのようになる筈。


「田中一郎さん、出勤。前回の退勤が本日です。確認して下さい。」
本日既に退勤しているのに再び出勤。ありえなくはないシチュエーションだが、一応確認を促す。再出勤が間違いなら30秒以内にもう一度カードをかざすと取り消される。


「山田太郎さん、30秒以内の "退勤" 取り消し。」
これは退勤するつもりが無いのに間違ってカードをかざして退勤になってしまったので30秒以内に再度カードをかざしたときのメッセージ。直前の "退勤" が取り消された(無効フラグ)。もちろん取り消し用の2回めのカードかざしも記録はするが無効フラグ。


「山田太郎さん、退勤。出勤が昨日です。確認して下さい。」
これは残業で日付が変わったなどでありえなくはないシチュエーションだが、昨日の退勤時にカードをかざし忘れた可能性を警告している。

利用者が警告メッセージを聞いて、本当に間違いであれば早急に管理者にデータを修正して貰うという運用を想定している。管理者がデータを修正するとか登録するとか記録されたデータを活用するためのアプリは自分で作ってねと。プログラムできなくてもAccessとかあれば簡単よ。

関連記事:


コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です