Edited at

kintone に保管したセンサ値を AWS Lambda で一定間隔でチェックし警告をメールで送信


処理内容

・15分毎に AWS Lambda を起動します。

・kintone アプリから警告用の設定を取得します。

・kintone アプリからセンサ値を取得します。

・センサ値が警告レベルなら AWS SES で警告メールを送信します。

・kintone アプリの警告状況を更新します。


kintone の設定

・計測値を保管するアプリを追加して、計測値を保管します。

・警告メールの送信条件を保管するアプリを追加して、設定を保管します。(同時に警告の発生状況を保管。)

・kintone のアプリには Lambda の API リクエストのための、 APIトークンを設定しておきます。


AWS SES の設定

AWS SES でメールを送信します。

今回は、送信者認証でメールを送信できるように設定し、利用します。

設定は以下を参考にしてください。

Amazon SESによるメール送信環境の構築と実践

https://dev.classmethod.jp/cloud/aws/amazon-ses-build-and-practice/

自動でメール配信をしたい!Amazon SESで効率的なメール配信をしよう!

https://style.potepan.com/articles/11667.html


AWS Lambda の設定

AWS Lambda の設定については以下を参考にしてください。

今度こそ理解する!俺式Lambda入門

https://dev.classmethod.jp/cloud/aws/lambda-my-first-step/

AWS Lambda 関数を cron のように定期実行

https://www.qoosky.io/techs/89d52682fb


Lambda のコード

以下は sync-request を利用していますが、node.js が 7.6 以降であれば then-request と Promise await で実装することをお勧めします。

Promise, async, await がやっていること

https://qiita.com/kerupani129/items/2619316d6ba0ccd7be6a

then-request

https://www.npmjs.com/package/then-request

Node.jsのHTTP通信はsync-request非推奨でthen-requestを推奨

https://designetwork.daichi703n.com/entry/2017/02/21/node-then-request


index.js

'use strict';

var aws = require('aws-sdk');
var ses = new aws.SES({apiVersion: '2010-12-01', region: 'ap-northeast-1' });

var request = require('sync-request');

var domain = "cybozu.com";
var subdomain = "SUBDOMAIN";
var path = "/k/v1/records.json";
var protocol = "https://";

var setAppId = "KINTONE_SETTING_APP_ID";
var setToken = "SETTING_KINTONE_APP_TOKEN";

var dataAppId = "KINTONE_DATA_APP_ID";
var dataToken = "DATA_KINTONE_APP_TOKEN";

var to = ['TO_MAIL_ADDRESS']; // AWS SES でverify済みのメールアドレス(送信先)
var from = 'FROM_MAIL_ADDRESS'; // AWS SES でverify済みのメールアドレス(送信元)

exports.handler = function(event, context) {
// 15 分前を算出
require('date-utils');
var dt = new Date();
var dtAgo = new Date();
dtAgo.setTime(dt.getTime() - (15 * 60 * 1000));

// kintone アプリからデータチェック用設定情報を取得
var url = protocol+ subdomain + '.' + domain + path;
var query = "Status" + 'in ("有効") order by PlaceId asc limit ' + count;
var settingRecodes = funcGetKintoneRecodes(request, setAppId, url, setToken, query);

// kintone アプリから現在のデータを取得
var query = "DateTime" + ' < "' + dtAgo.toFormat("YYYY-MM-DDTHH24:MI:SSZ") + '"' + ' order by PlaceId asc, DateTime desc limit 50';
var dataRecodes = funcGetKintoneRecodes(request, dataAppId, url, dataToken, query);

// 取得したデータのチェック
var errorData = funcCheckData(settingRecodes, dataRecodes);
if(errorData.length > 0){
funcSendMails(ses, to, from, errorData);
funcPutRecodes(request, url, setAppid, setToken, errorData);
}
};

// データチェック処理
function funcCheckData(settingRecodes, dataRecodes)
{
var recodes = [];
if (settingRecodes.length == 0 || dataRecodes.length == 0){
return recodes;
}

// 計測場所毎のデータに集約
var dataPlaceId = "";
var uniqueData = [];
dataRecodes.forEach(function(record){
if(dataPlaceId != dataRecodes['PlaceId']['value']){
uniqueData[dataRecodes['PlaceId']['value']]['DateTime'] = dataRecodes['DateTime']['value'];
uniqueData[dataRecodes['PlaceId']['value']]['Temperature'] = dataRecodes['Temperature']['value'];
uniqueData[dataRecodes['PlaceId']['value']]['Humidity'] = dataRecodes['Humidity']['value'];
uniqueData[dataRecodes['PlaceId']['value']]['CO2'] = dataRecodes['CO2']['value'];
uniqueData[dataRecodes['PlaceId']['value']]['SaturationDeficit'] = dataRecodes['SaturationDeficit']['value'];
dataPlaceId = dataRecodes['PlaceId']['value'];
}
});

var i = 0;
settingRecodes.forEach(function(record){
// 場所毎に集約したデータを設定値と突き合わせ
var settingPlaceId = record['PlaceId']['value'];
if(uniqueData[settingPlaceId] != null){
var isNormal = true;
var item = "";
switch (record['DataType']['value']){
case "温度":
item = "Temperature";
break;
case "湿度":
item = "Humidity";
break;
case "飽差":
item = "SaturationDeficit";
break;
case "CO2濃度":
item = "CO2";
break;
}

// 警告対象外(通常値)かどうか判断
if(record['Condition']['value'] == "以上"
&& record['Value']['value'] <= uniqueData[settingPlaceId][item]){
isNormal = false;
}
if(record['Condition']['value'] == "以下"
&& record['Value']['value'] >= uniqueData[settingPlaceId][item]){
isNormal = false;
}
}

// 警告を通知
if(!isNormal && (record['StartDateTime']['value'] == "" || (record['StartDateTime']['value'] != "" && record['EndDateTime']['value'] != ""))){
recodes[i]['Normal'] = isNormal;
recodes[i]['Subject'] = "<警告発生>" + record['Title']['value'];
recodes[i]['Body'] = "場所:" + record['Title']['PlaceName'] + "\n";
recodes[i]['Body'] += "設定:" + record['DataType']['value'] + "" + record['Value']['value'] + " " + record['Condition']['value'] + " 現在値( " + uniqueData[settingPlaceId][item] + "";
recodes[i]['Body'] += "\n" + record['Message']['value'];
recodes[i]['No'] = record['Title']['レコード番号'];
recodes[i]['DateTime'] = uniqueData[settingPlaceId]['DateTime'];
i++;
// 警告を解除
}else if(isNormal && record['StartDateTime']['value'] != "" && record['EndDateTime']['value'] != ""){
recodes[i]['Normal'] = isNormal;
recodes[i]['Subject'] = "<警告解除>" + record['Title']['value'];
recodes[i]['Body'] = "場所:" + record['Title']['PlaceName'] + "\n";
recodes[i]['Body'] += "設定:" + record['DataType']['value'] + "" + record['Value']['value'] + " " + record['Condition']['value'] + " 現在値( " + uniqueData[settingPlaceId][item] + "";
recodes[i]['No'] = record['Title']['レコード番号'];
recodes[i]['DateTime'] = uniqueData[settingPlaceId]['DateTime'];
i++;
}
});
return recodes;
}

// 警告メールを送信
function funcSendMails(ses, to, from, errorData)
{
errorData.forEach(function(record){
funcSendMail(ses, to, from, record['Subject'], record['Body']);
});
}

// 警告状況をkintoneの設定ファイルに反映
function funcPutRecodes(request, url, appid, token, errorData)
{
errorData.forEach(function(record){
var id = record['No'];
var json;
if(record['Normal']){
json = { "EndDateTime": record['DateTime'] };
}else{
json = { "StartDateTime": record['DateTime'] };
}
funcPutKintoneRecode(request, url, appid, token, id, json)
});
}

// メールの送信
function funcSendMail(ses, to, from, subject, body)
{
var eParams = {
Destination: {
ToAddresses: to
},
Message: {
Body: {
Text: {
Data: body,
Charset: 'iso-2022-jp'
}
},
Subject: {
Data: subject,
Charset: 'iso-2022-jp'
}
},
Source: from
};

var email = ses.sendEmail(eParams, function(err, data){
if(err){
context.fail(new Error('Mail error occured.'));
} else {
context.succeed('Mail sucsess.');
}
});
}

// kintone のデータを取得する
function funcGetKintoneRecodes(request, appId, url, token, query)
{
if(count > 500) count = 500;
var appUrl = url + '?app=' + appId + '&query=' + encodeURI(query);
var respons = request('GET', appUrl, {
'headers': {
'X-Cybozu-API-Token': token
}
});
return JSON.parse(respons.getBody());
}

// kintone のデータを更新する
function funcPutKintoneRecode(request, url, appid, token, id, json)
{
var options = {
url: url,
method: 'PUT',
headers: {
'Content-type': 'application/json',
'X-Cybozu-API-Token': token
},
body: { app : appid, id : id, record: json },
json: true
};

request.put(options, function (err, res, body) {
console.log('response' + res.statusCode);
if (!err && res.statusCode === 200) {
console.log('response SUCCESS!!');
} else {
console.log('response error: ' + res.statusCode);
}
console.log('response end');
});
}



参照情報

AWS Lambda ( https://aws.amazon.com/jp/lambda/ )

AWS SES ( https://aws.amazon.com/jp/ses/ )

【Python3】AWS 「API Gateway」「Lambda」「SES」を使って、RaspberryPiでのセンサー検知をメール通知してみた ( http://raspi.seesaa.net/article/431653483.html )

AWS SES+Lambda で作る、ドメインまるっとメール転送 ( http://iseebi.hatenablog.com/entry/2016/05/05/123815 )

sync-request ( http://designetwork.hatenablog.com/entry/2016/11/19/sync-request-post-node-js )

then-request ( https://www.npmjs.com/package/then-request )