Unipos engineer blog

Uniposの開発者ブログ

社食を支える技術(後編)

こんにちは、小紫です。

年の瀬に「社食を支える技術」の後編記事を書きました。

前編はこちら。

fringeneer.hatenablog.com

前編では給食当番として選出するあたりの実装について紹介しました。
後編では、毎日の利用チェックをどうやって実現しているかについて説明します。

社食の利用チェック

前編から引用すると、

また、社員食堂の利用料を一部会社が福利厚生という形で補助するため「誰が何回利用したか」を記録する必要があります。
詳しいくことは省きますが税金の計算上、社食の利用チェックは非常に重要なのです。

要するに「社食を誰が何回利用したかを記録する」を何とか実現しないといけません。

これに対する対応策としては以下のような物が考えられるかなと。

  1. チェックシートに手書きする
  2. Googleフォームで回答してもらう
  3. その他

1は集計するのが非常に大変だから無し。
2は100人以上の中から名前を選択するだけで時間がかかってしまうので社食の体験を損ねてしまいます。
ということで「その他」です。
社食の利用料決済にはSUICAなどの交通系ICカードが利用できるようになっており、それと同様の体験を目指しました。
弊社の社員証にはFelicaが入っているのを利用して、社員証を「ピッ」とやればチェックされるようないい感じのシステムを実装します。

もう少し具体的には、「誰がいつ社食を利用したか」を保存するのに、ICカードリーダで社員証を読み取って外部のAPIにHTTPリクエストを送信するようなシステムを作ればよさそうです。
RaspberryPiを使ってアプリケーションを動かし、接続したICカードリーダの情報を使ってWebサーバとやり取りする、というアーキテクチャです。
ICカードリーダにはこれを利用しました。
https://www.amazon.co.jp/gp/product/B00948CGAG

また、「誰がいつ社食を利用したか」を管理するはスプレッドシートの得意なところです。
前編に引き続きGASを使ってスプレッドシートAPI経由で操作出来るようにしてみます。

まとめるとアーキテクチャはこんな感じです。

f:id:fringeneer:20181222220826p:plain
社食利用チェックのアーキテクチャ

やらなければいけないことをまとめると、

  • 社員証と名前を紐付け登録出来るようにする
  • 社員証を読み取ると、その人についてその日付でチェックされる

という感じです。

社員証と名前を紐付け登録出来るようにする

社員証があるならそれだけでいいじゃん、と思うかもしれませんが単なるFelicaでしかない社員証には個人情報が入っていません。
ただの番号です。
なので「誰が」という情報に使うには付加情報が必要になります。

データ構造は簡単に[登録日時、ICカード番号、名前]としました。
こんな感じです。

f:id:fringeneer:20181222220851p:plain
ICカード番号と名前のマッピング

ICカード番号と名前のマッピングを保存するためのWebアプリケーションをGASで開発します。
実装はこのようになりました。

// HTTPエンドポイントになる
function doPost(e) {
  // HTTPリクエストのbodyから取得する
  var { felica: felica, name: name } = JSON.parse(e.postData.getDataAsString());

  if (felica && name && !getMappings()[felica]) {
    // ICカード番号も名前も空文字でなく、かつすでに登録されていない場合は新規追加
    appendRow(felica, name);
  }
  // 適当にレスポンスを返す
  return ContentService.createTextOutput(JSON.stringify({ status: 200, message: "OK" })).setMimeType(ContentService.MimeType.JSON); 
}

var mappingSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('mapping');

// 登録済みのデータを取得する
var getMappings = function() {
  // スプレッドシートからデータ読み取り
  var mappingRange = mappingSheet.getRange(2, 1, mappingSheet.getLastRow(), 3);
  var mapping = {};
  mappingRange.getValues().forEach(function(row) {
    mapping[row[1]] = row[2]; // felica -> name
  });
  return mapping;
};

// 新規データ追加
var appendRow = function(felica, name) {
  var appendRange = mappingSheet.getRange(mappingSheet.getLastRow() + 1, 1, 1, 3);
  var dateTime = formatDateTime(new Date());
  appendRange.setNumberFormat('@'); 
  appendRange.setValues([[dateTime, felica, name]]);
}

Felicaの番号(IDm)は16桁の16進数で、上位4桁は製造者コード、続く12桁がカード識別番号となっています。
参考: Sony Japan | FeliCa
変に表示形式でおかしくならないようにsetNumberFormat('@')しておくことで安全に扱うことが出来ます。

新規登録が出来るようになったので次はIDmに対応する名前を取得するエンドポイントも同様に実装しておきます。
データ構造は{ 名前: { 日付: boolean }}というイメージです。

function doGet(e) {
  var felica = e.parameter.felica.toString();
  var name = getMappings()[felica];
  var response;

  if (name) {
    try {      
      updateCount(name); // 利用者チェック!
      response = { status: 200, message: "OK", name: name };
    } catch(err) { 
      response = { status: 500, message: "FAIL" + err, name: name };
    }
  } else {
    response = { status: 404, message: "NOT FOUND" + felica, name: null };
  }
  return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(ContentService.MimeType.JSON); 
}

var updateCount = function(name) {
  var checkSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('check');
  var checkDataRange = checkSheet.getDataRange();
  var checkData = checkDataRange.getValues();
  var nameIdx = (function() {
    for(var i = 0; i < checkData.length; i++) {
      if (checkData[i][0] === name) { return i; }
    }
    return -1;
  })();
  if (nameIdx === -1) {
    var newRow = new Array(checkData[0].length);
    newRow[0] = name;
    for (var i = 1; i < newRow.length - 1; i++) {
      newRow[i] = '';
    }
    newRow[newRow.length - 1] = 1;
    checkData.push(newRow);
  } else {
    var targetCountResults = checkData[nameIdx];
    
    targetCountResults[targetCountResults.length - 1] = 1;

    checkData[nameIdx] = targetCountResults;
  }
  checkSheet.getRange(1, 1, checkData.length, checkSheet.getLastColumn()).setValues(checkData);
};

これによって以下のように利用チェックを行うことが出来るようになりました。

f:id:fringeneer:20181222221001p:plain
社食の利用チェック

社員証を読み取ると、その人についてその日付でチェックされる

ICカード番号(IDm)と名前のペアをGAS経由で保存する、そしてIDmを投げると利用チェックすることが出来るようになりました。
続いては社員証ICカードリーダで読み取るところです。
実装にはPython2系(!)でnfcpy/nfcpyを利用します。
ちなみにPython3系に対応中の様子。

github.com

nfcpyを使ってICカードリーダからデータ読み取りするあたりの実装はこんな感じです。
nfcpyについて詳しくはドキュメントを参照して下さい。

# -*- coding: utf-8 -*-
import binascii
import nfc
from time import sleep
from logger import logger

class NfcChecker(object):
    WAIT_SEC = 3

    def __init__(self):
      pass

    def sense(self): # カードリーダーからの読み取りを実行する
        with nfc.ContactlessFrontend('usb') as clf:
            target = nfc.clf.RemoteTarget("212F") # Felica
            res = clf.sense(target) # 読み取り
            if res is None:
                return None, None
            tag = nfc.tag.activate(clf, res)
            idm = binascii.hexlify(tag.idm)
            
            if isinstance(tag, nfc.tag.tt3.Type3Tag):
                logger.debug('detected. idm = ' + idm)
                result = SendRequest().run(idm) # GASにリクエスト送る
                return idm, result
            else:
                logger.warn('unknown type. tag = ' + tag)
                return idm, None

if __name__ == '__main__':
    NfcChecker().run()

これをfelica.pyという名前で保存します。
python felica.pyすると、その瞬間にICカードリーダに乗っているカードのIDmを読み取って外部にリクエストを送信します。
リクエスト送るあたり(SendRequest)は普通にrequests使ってJSONで投げるだけで面白くないので割愛します。
GASだからといって特に何も特別なことは必要ありません。

続いてエントリポイントとなるapp.pyの実装です。
Flaskを使って簡単なWebアプリとして実装します。

一部分だけ抜粋すると、こんな感じ。

@app.route('/')
def waiting():
    # ICカードリーダで読み取ってGASにチェック内容を保存する
    idm, result = felica.KitchenChecker().sense()

    if idm is None:
        # 読み取れなかった場合は
        return render_template('ok.html', title = u'利用者チェック', name = None)
    if result is not None and result[u'status'] == 200:
        # 読み取れた場合は名前を画面に表示する
        return render_template('ok.html', title = u'利用者チェック', name = result[u'name'])
    else:
        # ICカードが未登録なので新規登録画面に遷移する
        return redirect(url_for('append', idm = idm))
        
if __name__ == "__main__":
    app.run(host='0.0.0.0', debug=True)

ICカードリーダを読み取ってGASに問い合わせ、対応する名前を結果として表示するようなWebアプリです。
Jinja2を使ってhtmlを作成して表示するようにしています。 この実装だと問題があって、ICカードリーダにカードが接触したタイミングでその読み取ったイベントからこのルートが発火してGASにリクエスト送ったりしないといけません。 が、当然、そんな出来るかわからない難しいことを作り込むのは大変なので雑に解決します。
どう解決したかは./template/ok.htmlを見ればわかります。

{% extends "layout.html" %}
{% block content %}
<h1>利用者チェック</h1>
<h2 class='name'>
  {% if name %}
    {{ name }}: OK!
  {% else %}
    waiting...
  {% endif %}
</h2>

<script type="text/javascript">
  (function() { setTimeout("location.reload(true)", 2000); })();
</script>

{% endblock %}

JavaScriptで書かれているところをみると、2000msごとにページをリロードしています。
これによって、ページがリロードしたタイミングでICカードリーダにカードが接触していれば無事に読み取って結果を表示することが出来ます。
リロードしたタイミングでカードが接触していなければwaiting...と表示されて次のリロードでまたチャレンジする、というのを無限に繰り返しています。
whileループ的なイメージですね。

なお実際の運用では2秒ごとに読み取った結果が消えてしまうのは不便なので、 一時的にキャッシュしておいて新しくICカードを読み取るまでは前の結果を表示し続けるようにしていたりします。

画面遷移はこのようになります。

f:id:fringeneer:20181222221605p:plain
画面遷移

デザイン性が皆無ですね、いいんです。これで。

RasbperryPiで動かす

Pythonで作ったアプリケーションをどうやって動かすか、ですが社食の側に常設しておきたかったので小型で放置していいようなもの、ということでRaspberryPiです。 セットアップはRasbianをRasberryPi 3 ModelBに載せただけなので割愛します。Web上にたくさん情報も出ています。
先程のPythonアプリケーションをRasberryPiにつめばOKです。

とりあえずRaspberryPiが再起動(電源抜き差し)したタイミングで自動的にアプリケーションが起動されるようにしておきます。

/etc/rc.localを以下のように編集します。

sh /home/pi/init.sh

exit 0

rc.local内で呼んでいる/home/pi/init.shはこのようになっています。

#!/bin/sh

sleep 60 # ちょっと待つ
sudo PYTHONIOENCODING=utf-8 python /home/pi/src/cafeteria_checker/app.py # さっきのapp.pyをこれで起動

これでアプリケーションが起動するのでブラウザからhttp://<RasbperryPiのIP>:5000/を開けばOKです。
とはいえDHCPしているとIPが再起動などのタイミングで不意に変わる可能性もあるため運用上不便です。
実際、不在なときにIPが変わってしまって「利用者チェック出来ないんですけど」と何度もメンションもらったものです。

そこでBonjour使えばいいんじゃないですか、と後輩からアドバイスをもらったので対応しました。
Rasberry Pi (raspbian)でBonjour - Qiitaに書いてある通りに /etc/hostnameと/etc/hostsを書き換えて再起動すれば、同一ネットワーク内であればそのhostnameでアクセス出来るようになります。

例えば、

$ cat /etc/hostname
hoge-raspberrypi

という状態であればhttp://hoge-raspberrypi.local:5000/でアクセスできるようになります。

このURLを開いたiPad等を社食らへんに設置すればインタフェースとして十分機能します。

作ったものまとめ

前編後編に分かれた上に飛び飛びで分かりづらいのでまとめておきます。

  • 給食当番の選出
    • GAS + SpreadSheet
    • 偏らないように回数を保存しておいたりスキップ用フラグをもたせたり
  • 給食当番のSlack通知/リマインド
    • GASからSlack APIを叩く
  • ICカード番号(IDm)とユーザ名のマッピングを保存する
    • GASでWebアプリ + SpreadSheet
  • ICカード番号(IDm)を受け取って社食の利用チェックを保存
    • GASでWebアプリ + SpreadSheet
    • ユーザごとに各日付について利用したかどうかを保存
  • ICカードICカードリーダで読み取って利用チェック
    • Python2 + nfcpy + Flask on RaspberryPi
    • ブラウザの定期リロードでICカードリーダをキックする
    • RaspberryPiはBonjourを使ってIPが変わっても大丈夫なようにしておく

たかが社食、されど社食という感じの開発量になりました。
軽い気持ちで作り始めたらこんなことになるとは、という感想ですね。 とはいえ社内で実際に動いていて運用に乗っていて意外と安定しているので満足です。