話題の記事

[日本語Alexa] 社員証をかざして出退勤を記録するスキルを作ってみた

2018.08.18

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

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を利用させて頂きました。

$ sudo pip install nfcpy
$ git clone https://github.com/nfcpy/nfcpy.git

サンプルコードで、動作の確認を行うことができます。

$ sudo python nfcpy/examples/tagtool.py
No 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の北海道版)を読ませてみると下記のように表示されてました。

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しているのは、カードリーダーデバイスの使用のための権限です)

$sudo node ./card.js Device001

card.js

<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();
            let data = {
                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);
        }
    }
});

function create_datetime_string() {
    var now = new Date();
    return now.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 function card_read(){
    return new Promise((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 function chime(){
    return new Promise((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です。

const RecordIntentHandler = {
    canHandle(h) {
        return   isMatch(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);

        let speak = '';
        if(card_id == undefined){
            speak = '社員証をかざしてからご利用ください';
        } else if (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"/>退勤を記録しました。'
            }
        }
        return h.responseBuilder
            .speak(speak)
            .getResponse();
    }
};

7 勤怠記録DBの更新とWebページの更新

スキルで社員証が認識され、勤怠記録が走ると、出勤若しくは、退勤の時間がDBに保存されます。

このDBが更新されたタイミングで発火されるLambdaは、下記のとおりです。

予めブラウザ表示用のページの元となるファイル(base.html)をS3に配置しておき、そのファイルを読み込んで、データベースの内容を反映したindex.htmlを更新します。また、同時に、ブラウザに対してMQTTで更新されたことを伝え、リフレッシュさせています。

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 = new AWS();

    // S3からbase.htmlをダウンロードする
    const srcData = await aws.s3_get(bucket, srcHtml);
    let text = 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 ) return 1;
            if( a.datetime.S > b.datetime.S ) return -1;
            return 0;
        });
        let n = 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でブラウザに更新されたことを伝える
    let result =  await aws.iot_publish(topic, endpoint, '{"action":"refresh"}');
    console.log(result);
};

予め用意されているファイル(base.html)は、以下のようなものです。

そして更新されたファイル(index.html)は、次のようになります。

8 最後に

今回は、カードリーダーとAlexaの連携を試してみました。 AWS Iot を経由することで、AWSのリソースと簡単に連接できました。

なんでも自由につなげそうですが、あくまで、起動がAlexa側になるので、そのUIをどのように設計するかは、非常に大事だと感じました。

カードをかざしたときの各種の効果音は、効果音ラボのものを利用させて頂きました。