[日本語Alexa] 社員証をかざして出退勤を記録するスキルを作ってみた
1 はじめに
今回は、カードリーダーとAlexaの連携サンプルとして、出退勤を記録するスキルを作ってみました。
社員証を使用することで、誰がAlexaに話しかけたのかを判別しています。
最初に、動作している様子をご覧ください。
社員証をかざすと出退勤が記録できます。社員証をかざさなかったり、社員証以外のカード(Felica)を提示すると怒られます。
2 構成
構成は、図のとおりです。
カードリーダーに社員証をかざすと、IDを読み取ってMQTTでパブリッシュします。AWS IoT Coreでは、ルールに基づいてその内容をDynamoDB(カード情報保存DB)に書き込みます。
Echoから呼び出されたスキルは、最初にカード情報保存DBを確認し、10秒以内に更新された情報を対象に、カードのIDが有効なものかを確認してから動作を初めます。
スキルは、社員証が有効な場合のみ、勤怠記録DBに出勤若しくは、退勤を記録します。
勤怠記録DBが更新されると、Webページ更新用のLambdaが起動され、表示用のWebページを更新してS3に格納します。また、同時にMQTTで、ブラウザにページが更新されたことを伝えます。
ブラウザは、サブスクライブ状態で待機しており、トピックが来ると、ページをリフレッシュしています。
3 カードリーダー
今回使用したカードリーダーは、ソニー SONY 非接触ICカードリーダー/ライター PaSoRi RC-S380です。
PythonでNFCリーダを取り扱うライブラリとしてnfcpyを利用させて頂きました。
| 1 2 | $ sudo pip install nfcpy$ git clone https://github.com/nfcpy/nfcpy.git | 
サンプルコードで、動作の確認を行うことができます。
| 1 2 3 4 5 | $ sudo python nfcpy/examples/tagtool.pyNo handlers could be found for logger "nfc.llcp.sec"[nfc.clf] searching for reader on path usb[nfc.clf] using SONY RC-S380/P NFC Port-100 v1.11 at usb:001:004** waiting for a tag ** | 
試しにKitaca(Suicaの北海道版)を読ませてみると下記のように表示されてました。
| 1 | Type3Tag 'FeliCa Standard (RC-S???)' ID=010102XXXXXXX0C25 PMM=100B4XXXXXXXD0FF SYS=0003 | 
4 AWS IoT Core
(1) Publish
作成した「モノ」は、attendanceです。証明書を発行し、デバイスにコピーします。
パブリッシュが正常に動作しているかどうかは、AWSコンソールのテストで確認できます。
(2) ルール
続いて、ルールを設定して、publishされたデータをDynamoDBに書き込みます。
テーブルは、予め作成しておきます。
そして、AWSコンソールのACTからルールを追加します。
DynamoDBへのアクションの設定は、以下のようになっています。
正常に動作するとDynamoDBのデータが更新されることを確認できます。
5 コード(RaspberryPi上)
デバイス側でカードをIDを読み取って、パブリッシュするコードは、以下のとおりです。
コマンドラインからは、以下のように使用します。(sudoしているのは、カードリーダーデバイスの使用のための権限です)
| 1 | $sudo node ./card.js Device001 | 
card.js
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | <br />const deviceModule = require('./node_modules/aws-iot-device-sdk').device;const exec = require('child_process').exec;const topicName = 'attendance_card';const host = 'xxxxxxxxx.iot.us-east-1.amazonaws.com'const region = 'us-east-1';const clientId = 'attendance';const device_id = process.argv[2];const device = deviceModule({    keyPath: './cert/private.key',    certPath: './cert/cert.pem',        caPath: './cert/root-CA.crt',    clientId: clientId,        host: host,        region: region,    reconnectPeriod:10});device.on('connect', async () => {    // MQTT接続完了    console.log('device connect');    while(true){        try{            // カード情報の読み取り            const card_id = await card_read();            letdata = {                date_time: create_datetime_string(),                device_id: device_id,                card_id : card_id,            };            // パブリッシュ            device.publish(topicName, JSON.stringify(data));            console.log("publish "+ JSON.stringify(data));            // チャイム            await chime();        } catch(error) {            console.log(error);        }    }});functioncreate_datetime_string() {    varnow = newDate();    returnnow.getFullYear() + '/'+               ("0"+ ( now.getMonth() + 1 ) ).slice(-2) + '/'+               ("0"+ now.getDate() ).slice(-2) + ' '+               ("0"+ now.getHours()).slice(-2) + ':'+                ("0"+ now.getMinutes()).slice(-2) + ':'+               ("0"+ now.getSeconds()).slice(-2);}async functioncard_read(){    returnnewPromise((resolve, reject) => {        exec('python ../nfc/nfcpy/examples/tagtool.py', (err, stdout, stderr) => {            if(err) {                reject(err);            } else{                const card_id = stdout.match(/ID=(.*?)\s/);                resolve(card_id[1]);            }        });    })}async functionchime(){    returnnewPromise((resolve, reject) => {        const cmd = 'mpg321 chime.mp3';        exec(cmd, (err, stdout, stderr) => {            if(err) {                reject(err);            } else{                console.log(cmd);                resolve("success");            }        });    })} | 
6 スキル作成
(1) インテント
インテントとして定義したのは、以下のようなものです。
- RecordIntent 記録して/タイムカード
- WorkingHoursOfDayIntent 今日の勤務時間は?/今日は何時間だった?
- WorkingHoursOfMonthIntent 先月の勤務時間は?/先月は何時間だった?
勤怠の記録を行っているメインのインテントはRecordIntentです。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | const RecordIntentHandler = {    canHandle(h) {        returnisMatch(h, 'RecordIntent', 'LaunchRequest');    },    async handle(h) {        console.log(JSON.stringify(h.requestEnvelope));        // DynamoDBからカードIDを取得する        const card_id = await cardRead('Device001');        // カードIDから社員の名前を取得する        const name = getNameFromCardId(card_id);        letspeak = '';        if(card_id == undefined){            speak = '社員証をかざしてからご利用ください';        } elseif(name == undefined) {            speak = 'IDが無効です。この社員証はご利用になれません。';        } else{            // 日付文字列の生成            const datetime = CreateDateTime();             const time = CreateTime();            // 勤怠記録DBに記録する(出勤を記録した場合1、退勤を記録した場合0が返される)            const state = await record(datetime, card_id);            if(state == 1) {                speak = name + 'さん、おはようございます。'+ time + '<break time="300ms"/>出勤を記録しました。今日もいちにち、宜しくお願いいたします。'            } else{                speak = name + 'さん、お疲れ様でした。 <break time="1s"/>'+ time + '<break time="300ms"/>退勤を記録しました。'            }        }        returnh.responseBuilder            .speak(speak)            .getResponse();    }}; | 
7 勤怠記録DBの更新とWebページの更新
スキルで社員証が認識され、勤怠記録が走ると、出勤若しくは、退勤の時間がDBに保存されます。
このDBが更新されたタイミングで発火されるLambdaは、下記のとおりです。
予めブラウザ表示用のページの元となるファイル(base.html)をS3に配置しておき、そのファイルを読み込んで、データベースの内容を反映したindex.htmlを更新します。また、同時に、ブラウザに対してMQTTで更新されたことを伝え、リフレッシュさせています。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | const AWS = require("./AWS");const tableName = 'attendance_record_table';const bucket = 'attendance-html';const srcHtml = 'base.html';const dstHtml = 'index.html';const endpoint = 'xxxxxxxxxx.iot.us-east-1.amazonaws.com';const topic = "attendance_refresh";exports.handler = async (event) => {    const aws = newAWS();    // S3からbase.htmlをダウンロードする    const srcData = await aws.s3_get(bucket, srcHtml);    lettext = decodeURIComponent(escape(String.fromCharCode.apply(null, srcData.Body)));    // DynamoDBの勤怠レコードでbase.htmlからindex.htmlを作成する    const scanData = await aws.dynamoDb_scan(tableName);    if(scanData) {        // 日付でソート        scanData.Items.sort(function(a,b){            if( a.datetime.S < b.datetime.S ) return1;            if( a.datetime.S > b.datetime.S ) return-1;            return0;        });        letn = 0;        scanData.Items.forEach ( item => {            if(n < 10){                text = text.replace('${DateTime_0'+ n + '}',item.datetime.S);                text = text.replace('${Name_0'+ n + '}',createNameString(item.card_id.S));                text = text.replace('${State_0'+ n + '}',createStateString(item.state.N));                n++;            }        });    }    // index.htmlをアップロードする    const contentType = "text/html";    await aws.s3_upload(bucket, dstHtml, text, contentType);    // MQTTでブラウザに更新されたことを伝える    letresult =  await aws.iot_publish(topic, endpoint, '{"action":"refresh"}');    console.log(result);}; | 
予め用意されているファイル(base.html)は、以下のようなものです。
そして更新されたファイル(index.html)は、次のようになります。
8 最後に
今回は、カードリーダーとAlexaの連携を試してみました。 AWS Iot を経由することで、AWSのリソースと簡単に連接できました。
なんでも自由につなげそうですが、あくまで、起動がAlexa側になるので、そのUIをどのように設計するかは、非常に大事だと感じました。
カードをかざしたときの各種の効果音は、効果音ラボのものを利用させて頂きました。