htmlとjsで1歳の子供が遊べるおもちゃを作った話
背景
最近、僕がリビングでPCを触っていると1歳3ヶ月になる子供がキーボードを的確に押してきて作業の邪魔をするようになった。
PCに興味を持つのはいいんだけど、子供がキーを押すと僕の作業の効率が著しく落ちる割に、「エディタ画面で文字がいくつか増える」っていう体験から子供が受ける刺激があんま大したことないのが気に入らなかった。そもそもたぶん僕のまねをするのが嬉しいだけで文字が増えてるの気づいてないし。
そこで、子供のランダムなキータイプに対してちょっと面白げな反応が返ってくるおもちゃをHTMLとJavaScriptで作ってみた。
ちなみに年初にエンジニアからコンサルタントに転身して半年ぐらい全然コード書いてなかったんだけど、以前なら30分ぐらいでできてたぐらいのものを作るのに2時間かかった。。
定期的になにか作らねば作り方忘れてしまうね。
成果物
- キーを押すたびにパステルカラーの●▲■がランダムに画面上に出る
- 数字キーを押すと図形が押した数字分一気に出る
- エンターキーまたはスペースキーの押下でア○パンマンかバイキ○マンが出る
一応作り方
全くたいしたことないけど一応作り方など。
現役時代はvue勉強してたんだけど、めんどかったのでjquery使った。
図形
図形は全部div要素で作りました。大きさと色はランダムにしたかったので%color%
とか%size%
とかしてあとから置き換えた。
triangle
は正三角形にしたかったのでstyle属性の中でcalcで√3掛けたりして計算してる。
const shapes = { circle: '<div class="shape circle" style="width: %size%px;height: %size%px;border-radius:50%;background: %color%;" />', rectangle: '<div class="shape rectangle" style="width: %size%px;height: %size%px;background: %color%;" />', triangle: '<div class="shape triangle" style="border-color: transparent transparent %color% transparent;border-style: solid;border-width: 0 calc(%size%px / 2) calc(%size%px * 1.7320508 / 2) calc(%size%px / 2);height: 0;width: 0;" />' };
図形の描画
こんな感じの関数を作って引数にランダムな色やらサイズやらを入れて描画している。
const objectDrawer = {}; objectDrawer.drawShapeWithDom = function (shape, size, color, position_x, position_y) { let obj = $(shapes[shape].replace(/%color%/g, color).replace(/%size%/g, size)) .css('left', 'calc(' + position_x + '% - ' + size / 2 + 'px)') .css('top', 'calc(' + position_y + '% - ' + size / 2 + 'px)') .css('position', 'absolute') .appendTo($('body')) .show() .fadeOut(3000); window.setTimeout(() => obj.remove(), 3000); };
jqueryのfadeOut
を使って消えるようにしてみた。
fadeOutしてもDOMが残り続けるのが嫌だったのでsetTimeout
でfadeOutしたあとのDOMは消してます。
キーイベントとの紐づけ
普通にonkeypressでランダムな図形かア○パンマン表示するだけ。
(少なくとも)Chromeだとキーを押しっぱなしにすると連続してkeypressとkeydownイベントが発火するみたいだったので、一回のキー押下では一回しか図形が出ないようにkeyIsBeingPressed
という変数で押しっぱなしかどうか判断するようにしてみた。
let keyIsBeignPressed = false; window.onkeypress = function (e) { if (keyIsBeignPressed) { return; } else { keyIsBeignPressed = true; } switch (e.code) { case 'Enter': case 'Space': objectDrawer.drawAnpanman(); break; default: if (/Digit|Numpad/.test(e.code)) { objectDrawer.drawRandomShapes(e.code.replace(/Digit|Numpad/, '')); } else { objectDrawer.drawRandomShape(); } } console.log(e.code); }; window.onkeyup = function (e) { keyIsBeignPressed = false; };
最後に
まだユーザ試行をやっていないので、今週末辺りにエンドユーザに触ってもらって、あーとかうーとかいうフィードバックを貰おうかと思っています。
平日の僕が帰る時間にはエンドユーザが寝てるので。
ユーザに喜んでもらえるように継続的にギミック仕込んで改良していく予定です。
なんだか初心者が初めてjs書いてみましたみたいな感じになってるけど、実は俺初心者じゃないんだぜ。。。
そのうち生活の役に立つでかめのアプリを真面目に作る予定です。構想だけはあります。
macOSにzabbix-agentを入れる
MacBook Proにzabbix-agentを入れようとしたらやたらハマったのでメモ。
参考にしたサイト
ハマった内容
zabbix_agentdを直接叩けば起動ができてzabbix-serverとの疎通も取れたが、OS起動時等にlaunchdから自動起動することができない。
結論
zabbix_agentdをシェルスクリプトでラップしてlaunchdから呼んだらなぜか普通に動いた。
顛末
brew install zabbix --without-server-proxy
でzabbix-agentをインストールして起動及びサーバとの疎通確認!楽勝だね!- 自動で起動するために
/System/Library/LaunchDaemons/com.zabbix.zabbix_agentd.plist
を下記のように作った!まだまだ余裕!
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>com.zabbix.zabbix_agentd</string> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <dict> <key>SuccessfulExit</key> <false/> </dict> <key>Program</key> <string>/usr/local/sbin/zabbix_agentd</string> </dict> </plist>
sudo launchctl load /System/Library/LaunchDaemons/com.zabbix.zabbix_agentd.plist
するも何故か起動しない。。- plistに下記の行を加えてログを出そうとしてみた。outとerrのファイルは作られるのに中身は空。
/var/log/system.log
にも何も手がかりが出ない。。。
<key>StandardOutPath</key> <string>/tmp/zabbix_agentd.out</string> <key>StandardErrorPath</key> <string>/tmp/zabbix_agentd.err</string>
- 下記のようなシェルスクリプトを作り、plistのProgramを置き換えてloadしてみたらちゃんとoutに
root
と出力された。
#!/bin/bash
whoami
- brewでインストールしたzabbix-agentがおかしいんじゃね!?と思ったのでアンインストールしてソースからビルドした。とりあえず最新版の4.0.1を入れた。ここはここで若干ハマったのでちょっと詳しく書いておく。
- バージョン以外はMacにZabbixエージェントを入れるにある通りに進めたが、
./configure --enable-agent
したらiconvが無いと怒られた。 brew install libiconv
したあとでもう一度./configure --enable-agent
したが、またiconvが無いと怒られて困った。パスも通ってるのに意味が分からないよ。。。- configureの中身を見てみたら
--with-iconv
というオプションがあるらしいので、./configure --enable-agent --with-iconv=/usr/local/Cellar/libiconv/1.15/bin/iconv
としてみたら通った。 make install
してビルド。
- バージョン以外はMacにZabbixエージェントを入れるにある通りに進めたが、
- 起動及びサーバとの疎通確認!launchdから起動しようとしたら失敗!ログも出ない!!
- 下記のようなシェルスクリプト
/usr/local/sbin/zabbix_agentd.sh
を作ってzabbix_agentdをラップしてみた。
#!/bin/bash echo "executed by $(whoami) at $(date)" zabbix_agentd=/usr/local/sbin/zabbix_agentd echo "call ${zabbix_agentd}" ${zabbix_agentd}
- plistのProgramを書き換えて
sudo launchctl load /System/Library/LaunchDaemons/com.zabbix.zabbix_agentd.plist
したら動いた!何でだろう!! - MacBookを再起動して
ps -el | grep zabbix
としたところ、意図したとおり自動起動されていた。
つまりこうしたらできるらしい
- zabbix-agentをインストールする。多分brewでもなんでもいい。
/usr/local/sbin/zabbix_agentd.sh
を作成して実行権限を付与する。
#!/bin/bash echo "executed by $(whoami) at $(date)" zabbix_agentd=/usr/local/sbin/zabbix_agentd echo "call ${zabbix_agentd}" ${zabbix_agentd}
/System/Library/LaunchDaemons/com.zabbix.zabbix_agentd.plist
を作成してオーナーをroot:wheel
にする。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>com.zabbix.zabbix_agentd</string> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <dict> <key>SuccessfulExit</key> <false/> </dict> <key>StandardOutPath</key> <string>/tmp/zabbix_agentd.out</string> <key>StandardErrorPath</key> <string>/tmp/zabbix_agentd.err</string> <key>Program</key> <string>/usr/local/sbin/zabbix_agentd.sh</string> </dict> </plist>
sudo launchctl load /System/Library/LaunchDaemons/com.zabbix.zabbix_agentd.plist
Google Spreadsheetで非常用持ち出し袋の在庫管理をする
背景
我が家で数年前に非常用持ち出し袋というものを作ったのですが、中身の乾パンだとか長期保存できる羊羹だとかがそろそろ古くなってきています。
また、最近新しく家族が増えたのもあって色々と中身の増強が必要そうなのと、賞味期限とか使用期限とかの管理も今までよりしっかりやりたいなーというのがありました。
そこで、Google Spreadsheetを使って在庫の表を管理し、Google Apps Scriptが週一で期限を自動でチェックして期限が切れそうなものをメールで通知するような仕組みを作りました。
実装
Spreadsheetを作る
シンプルにこんな感じのものを作りました。
単純にGoogle Spreadsheetの機能を使っただけなのでなんてことはないのですが、、
- 分類はちゃんとマスタ(別シートで分類の一覧を用意した)から選択するような入力制御をかけた
- チェックボックスが使えたので破棄したフラグをチェックボックスで表現してみた
- 期限が近いものには「期限アラート」が出るように関数を組んだ
というのが工夫した点です。
期限アラートを出す関数は下記のような感じです。
=IF(ISBLANK(F2),,IF(OR(DAYS(F2, TODAY()) > 30, G2 = TRUE), , "❗"))
実際には30とか❗とかは変更しやすいよう設定シートを作って外出ししていますが。
Spreadsheetができたので期限切れをチェックしてメールを送るスクリプトを書きます。
Google Apps Scriptを書く
Spreadsheetに紐付いたスクリプトを作成するのは簡単で、下記のように[ツール] > [スクリプト エディタ]をクリックするだけです。
期限切れが近いレコードを抽出する部分の実装
var inventory = {}; inventory.SHEET = '物品一覧'; inventory.COLUMNS = { CATEGORY : '分類', NAME : '物品名称', QUANTITY : '数量', SELLER : '購入元', AQUISITION_DATE : '購入日', EXPIRY_DATE : '使用期限/賞味期限', DISPOSED : '処分済', EXPIRY_ALERT : '期限アラート' }; inventory.getSheet = function () { if (!inventory.sheet) { inventory.sheet = SpreadsheetApp.getActive().getSheetByName(inventory.SHEET); } return inventory.sheet; }; inventory.getData = function () { var rawData = inventory.getSheet().getDataRange().getDisplayValues(); var keys; var data = []; for (var i = 0; i < rawData.length; i++) { var row = rawData[i]; if (i === 0) { keys = row; } else if (row[0] && row[1]) { var record = {}; for (var j = 0; j < keys.length; j++) { record[keys[j]] = row[j]; } data.push(record); } else { // when empty row is detected break; } } return data; };
Google Apps ScriptではSpreadsheetApp
というオブジェクトが用意されており、このオブジェクトを使ってSpreadsheetの操作ができます。
inventory.getData
では表をまるごと{列名 : 値}
の連想配列の配列として取得できるようにしました。
inventory.getExpiringItems = function() { return inventory.getData().filter(function(record) { return !!record[inventory.COLUMNS.EXPIRY_ALERT] }); };
inventory.getData
で取得した情報をフィルターして「期限アラート」列に何らかの値(❗
ですね)が入っているデータのみを抽出しています。
これで期限が切れそうな物品のみを表から抽出できるようになりました。
メール通知の実装
function checkInventory(isDebug) { var expiringItems = inventory.getExpiringItems(); if (expiringItems.length > 0) { var DEVELOPER = ['hogehoge@dummy.com']; // 私のメールアドレス var USER = ['fugafuga@dummy.com']; // 妻のメールアドレス var recipient = isDebug ? DEVELOPER : DEVELOPER.concat(USER); var subject = '備蓄品の期限切れ通知 (' + expiringItems.length + '件)'; var body = '下記の備蓄品の期限が近くなっています。\n\n'; for (var i = 0; i < expiringItems.length; i++) { var item = expiringItems[i]; body = body + '・' + item[inventory.COLUMNS.NAME] + ' (' + item[inventory.COLUMNS.EXPIRY_DATE] + ')\n'; } body = body + '\n詳細はスプレッドシートで確認してください。\nhttps://docs.google.com/spreadsheets/d/dummy/edit#gid=0'; MailApp.sendEmail(recipient.join(','), subject, body); Logger.log('sent e-mail : ' + {recipient : recipient, subject : subject, body : body}.toString()); } else { Logger.log('no expiring items'); } } function test() { checkInventory(true); }
Spreadsheetのときと同様にMailApp
というオブジェクトが用意されており、こいつのsendEmailというメソッドを叩くことでメールを送ることができます。
一応デバッグモードを用意し、isDebugがtrueのときには妻にメールが行かないようにしてあります。こういう気遣いはエンジニアとして大事ですよね。
特筆すべきことはないのですが、備蓄品の期限切れ通知 (3件)
のようなタイトルで、本文には期限切れ間近の物品名とSpreadsheetのURLが書いてあるメールが飛びます。
ここまで書いてエディタからtest()
を実行すると私のところにメールが飛びます。
そして、エディタのメニューで[編集] > [現在のプロジェクトのトリガー]と選ぶと下記のように週一でcheckInventory()
を実行する設定ができます。
まとめ
きっと探せばスマホアプリがある気がしますが、個人的にこういうたまにしか使わないものは専用アプリをごちゃごちゃ入れるよりもGoogle Spreadsheetかなにかに集約しておいたほうが無くさないし共有できるし楽です。
今回は非常用持ち出し袋を対象にしているので在庫の増減(特に減の方)が頻繁には発生せず、Spreadsheetでもそれほど煩雑ではないのでこの仕組が長く活躍してくれるはずです。
ちなみに、ずっと冷蔵庫の在庫を管理したいと思っているのですが、冷蔵庫レベルで増減が頻繁に発生するとSpreadsheetではすぐに実態と記録が乖離することが容易に予想できるので同じやり方はダメそう。。
バーコード読み取って入出庫登録できるようなデバイスが冷蔵庫に磁石でくっついてたりしたら行けるんじゃないかという気がするんですが、誰か作ってくれないだろうか。
入庫の周期とか賞味期限とかからネットショップで注文しろってサジェストしたりとか儲かる要素はある気がするので、冷蔵庫の在庫管理というサービスは既にありそう。
Google Apps Scriptの実行時間を改善した話
背景
先日これを作ったんですが、「アレクサ、育児ノートでうんちを記録して」みたいに頼んだときの応答が遅いという問題があり、妻から苦情が寄せられていました。 miyataro.hatenablog.com
処理の流れとしてはこんな感じです。
Lambdaの実行時ログを追ってみたところ、実行時間は4.5~5.0秒程度で、そのうちの殆どをGoogle Apps Script APIの実行が占めていました。ただし、私が書いたスクリプトの実行時間そのものは2秒程度で、それをAPIとして実行するときのオーバーヘッドがかなりあるようでした。
集計してないので一個適当に拾ったログから出した値を載せておくと以下のような感じでした。
処理 | 実行時間 |
---|---|
- Lambdaの処理全体 | 4807 ms |
- Google Apps Script APIの呼び出し | 4741 ms |
- Google Apps Scriptの実行時間 | 2065 ms |
- API実行にかかるオーバーヘッド(?) | 2676 ms |
- Lambda上のその他の処理 | 66 ms |
オーバーヘッドだとしたら長すぎだろっていう気がするけど、調べても何も出てこなかった。
とりあえず、こちらで簡単に改善できる部分のなかで改善の余地があるのが「Google Apps Scriptの実行時間」しかないので、ここを改善しました。
方針
一般的な情報の調査
まず、ここにある情報が全部入りな印象です。
Best Practices | Apps Script | Google Developers
ここに書いてある内容で速度改善に効果がありそうなものは下記。
- Minimize calls to other services → やってないので関係ない
- Use batch operations (Spreadsheetの読み書きはまとめてやったほうが早いよっていう内容)
→ もともとこれには結構気を使って作ったので改善の余地が少なそう - Use the Cache service → こんなのあったの知らなかった…
読み書きはまとめたほうが早いよっていうのは以下のサイトを読んで気をつけていたのでそれなりに出来ているはず。
Google Apps Scriptのスプレッドシート読み書きを格段に高速化をする方法
[GAS][スプレッドシート]処理速度を向上するには : 逆引きGoogle Apps Script
今までSpreadsheetの読み書きをしていた部分をCache使って高速化するのが効果大きそうなので、その方向で考えることにしました。
ぼくがかんがえたさいきょうの高速化
基本方針は以下の通り
- Amazon Echoに対する発話から同期的に起こる処理の中ではできるだけSpreadsheetの読み書きをしない
- キャッシュを利用した書込キューを実装する
- 書込はすぐには行わずに書込内容をキューに詰めて応答を返す
- Google Apps Scriptの定時実行機能を使ってバッチ処理でキューの中身を書込む
- うんちの記録に対して「本日○回目のうんちです」の応答を返すためにSpreadsheetの読込を行っているが、回数はキャッシュの中の値をインクリメントすることでカウントし、読込をなくす
- キャッシュを利用した書込キューを実装する
この記事では書込部分を扱います。
実装
キューの実装
キューはキャッシュに対してrecords-queue-1
, records-queue-2
...というキーで書込内容のJSONを詰めることで実装する。
enqueue用のポインタとdequeue用のポインタを別々に用意してそれらもキャッシュに置いておく。
まずはキューに詰めるところ。
var recordsBufferCache = {}; // 名前空間の定義 recordsBufferCache.QUEUE_CACHE_KEY_PREFIX = 'records-queue-'; recordsBufferCache.QUEUE_ENQUEUE_POINTER_KEY = 'records-queue-enqueue-pointer'; // 念のため、CacheService.getScriptCache()を何回も呼ばなくて良いようにした。 recordsBufferCache.getCache = function () { if (!recordsBufferCache.cache) { recordsBufferCache.cache = CacheService.getScriptCache(); } return recordsBufferCache.cache; }; // これがキューに詰めるメソッド。引数に書込む内容を受け取る。 recordsBufferCache.enqueue = function(row) { var startTime = Date.now(); Logger.log('enqueue started with row : ' + JSON.stringify(row)); var cache = recordsBufferCache.getCache(); // 次に詰めるキューのpointerをキャッシュから取得 var enqueuePointer = cache.get(recordsBufferCache.QUEUE_ENQUEUE_POINTER_KEY); if (enqueuePointer === null) { Logger.log('enqueuePointer is missing. Newly start from 0'); enqueuePointer = 0; } Logger.log('enqueuePointer is ' + enqueuePointer); // キャッシュにキューの情報を詰める。第三引数はキャッシュの保持時間で、最大値(21600sec = 6h)を指定。 cache.put(recordsBufferCache.QUEUE_CACHE_KEY_PREFIX + enqueuePointer, JSON.stringify(row) ,21600); // 次に詰めるキューのポインタをキャッシュに入れる enqueuePointer++; // implicitly converted to Number cache.put(recordsBufferCache.QUEUE_ENQUEUE_POINTER_KEY, enqueuePointer, 21600); var executionTime = Date.now() - startTime; Logger.log('enqueue took ' + executionTime + ' ms'); };
キューの内容をSpreadsheetに書込むところ
recordsBufferCache.QUEUE_DEQUEUE_POINTER_KEY = 'records-queue-dequeue-pointer'; // これが書込むメソッド。返り値は書込んだ行数。 recordsBufferCache.dequeue = function() { var startTime = Date.now(); Logger.log('dequeue started'); var cache = recordsBufferCache.getCache(); var enqueuePointer = cache.get(recordsBufferCache.QUEUE_ENQUEUE_POINTER_KEY); var dequeuePointer = cache.get(recordsBufferCache.QUEUE_DEQUEUE_POINTER_KEY); if (enqueuePointer === null) { Logger.log('enqueue has never been called. dequeue process will terminate'); return 0; } if (dequeuePointer === null) { Logger.log('dequeuePointer is missing. Newly start from 0'); dequeuePointer = 0; } Logger.log('enqueuePointer is ' + enqueuePointer + ', dequeuePointer is ' + dequeuePointer); var rowCnt = 0; for (var i = Number(dequeuePointer); i < enqueuePointer; i++) { Logger.log('processing with pointer ' + i); var rowStr = cache.get(recordsBufferCache.QUEUE_CACHE_KEY_PREFIX + i); Logger.log('Write a new record : ' + rowStr); var row = JSON.parse(rowStr); // ループの中で都度書き込みするのはアンチパターンだが、この処理はバッチ実行するのである程度速度がかかることは許容した records.getSheet().appendRow(row); // 消費済のキューは削除 cache.remove(recordsBufferCache.QUEUE_CACHE_KEY_PREFIX + i); // 次に読むキューのポインタをキャッシュに詰める cache.put(recordsBufferCache.QUEUE_DEQUEUE_POINTER_KEY, i + 1, 21600); rowCnt++; } var executionTime = Date.now() - startTime; Logger.log('dequeue took ' + executionTime + ' ms'); return rowCnt; };
このような内部関数を作り、それを呼び出す関数を以下の通り実装した。
function writeRecordsInQueue () { var startTime = Date.now(); Logger.log('writeRecordsInQueue started'); var newRowCount = recordsBufferCache.dequeue(); if (newRowCount > 0) { // これはレコード追加に伴ってフロントとして使っているシートを更新する処理 dashboard.updateDashboardOnRecordsChange(true); } var executionTime = Date.now() - startTime; Logger.log('writeRecordsInQueue took ' + executionTime + ' ms'); }
この関数を定期実行するよう設定すればOK
キャッシュの期限切れを防止
Google Apps Scriptの仕様でキャッシュは最大6時間で期限切れになってしまうので、定期的にキャッシュを詰め直すことでキャッシュの期限切れを防止してみた。
こういうのGoogle的には絶対やってほしくないやつだと思うけど、、、
function maintainCache () { var startTime = Date.now(); Logger.log('maintainCache started'); var cache = recordsBufferCache.getCache(); [ recordsBufferCache.QUEUE_ENQUEUE_POINTER_KEY, recordsBufferCache.QUEUE_DEQUEUE_POINTER_KEY ].forEach(function (key) { var value = cache.get(key); cache.put(key, value, 21600); Logger.log('[key='+ key + ', value=' + value + ']'); }); var executionTime = Date.now() - startTime; Logger.log('maintainCache took ' + executionTime + ' ms'); }
これを4時間おきに実行するようにした。
まとめ
はじめはこんな感じだった実行時間が
処理 | 実行時間 |
---|---|
- Lambdaの処理全体 | 4807 ms |
- Google Apps Script APIの呼び出し | 4741 ms |
- Google Apps Scriptの実行時間 | 2065 ms |
- API実行にかかるオーバーヘッド(?) | 2676 ms |
- Lambda上のその他の処理 | 66 ms |
こんな感じになりました。
処理 | 実行時間 |
---|---|
- Lambdaの処理全体 | 1993 ms |
- Google Apps Script APIの呼び出し | 1961 ms |
- Google Apps Scriptの実行時間 | 192 ms |
- API実行にかかるオーバーヘッド(?) | 1769 ms |
- Lambda上のその他の処理 | 32 ms |
キューの実装だけじゃなくて他のIOも色々とキャッシュ利用するロジックに変えたので、色々合わさった結果ですが、結構改善できました。(まだ遅いけど)
全体としての修正内容はこんな感じ。
github.com
あと気になるのは下記。
- キャッシュの読み書きでロック機構が必要ではないか(基本的に更新頻度が低いので今のところ問題は起こっていない)
- API実行のオーバーヘッド大きすぎではないか
また何かあったら書きます。
Google Spreadsheetで管理している育児記録にGoogle Formsから情報を登録する
背景
この間、こんなものを作りました。 miyataro.hatenablog.com
今はまだ子供の一ヶ月検診が終わっていなくてずっと家にいるので常にAmazon Echoが近くにあるが、そのうち外出するようになれば買物中におむつを替えたりするはず。
そのときには当然Echoはないのでその場でポチッと登録できるインターフェイスが必要になる。
という事情から、Google Spreadsheetの育児記録にサクッとイベントを登録できる画面を作った。
使用したのはGoogle Formsで、Google Spreadsheetとの連携がすごく簡単にできるので結構すんなり作れた。
実装
Google Formsの作成
育児記録を管理しているSpreadsheetからフォームを作成して編集する。
フォームの中身はこんな感じ。
Spreadsheet側のGoogle Apps Scriptへトリガー追加
フォームを作成したはいいが、フォーム専用のシートが新しく作成されてそこに送信内容が溜まっていくという仕組みになっている。今回はAlexa経由で登録したときと同じくrecordsというシートに溜まっていってほしい。
調べてみると、Google Spreadsheetに紐付いたGoogle Apps Scriptで利用可能なトリガーの中にForm submit
というものがあったのでこれを使う。
Event Objects | Apps Script | Google Developers
Google Apps Scriptのイベントにはsimpleとinstallableというのがあって、simpleはonOpen
のような特定のイベントをハンドルする関数の名前があらかじめ定義されており、その関数を実装すればいいやつみたい。installableなイベントは、設定画面から任意の関数に紐付けることができるらしい。
今回使うForm submitイベントはinstallableなので自分で好きな名前の関数を作ってそいつを設定画面から紐付ける感じになる。
ということで、とりあえずSpreadsheetからスクリプトエディタを開いて関数を作ってみた。(説明のために若干簡単にしてある)
function onFormSubmit(e) { var dateKey; var eventKey; var milkKey; var keys = Object.keys(e.namedValues); keys.forEach(function(key) { if (key.indexOf('日時') > -1) { dateKey = key; } else if (key.indexOf('イベント') > -1) { eventKey = key; } else if (key.indexOf('ミルク') > -1) { milkKey = key; } }); var date = e.namedValues[dateKey][0] ? new Date(e.namedValues[dateKey][0]) : new Date(); var events = e.namedValues[eventKey][0].split(/,\s*/); var milkVolume = e.namedValues[milkKey][0]; if (events.indexOf(TYPE_NAME.unchi) > -1) { records.appendJournalRecordWithSpecificDate(date, TYPE.UNCHI); } if (events.indexOf(TYPE_NAME.oshikko) > -1) { records.appendJournalRecordWithSpecificDate(date, TYPE.OSHIKKO); } if (events.indexOf(TYPE_NAME.oppai) > -1) { records.appendJournalRecordWithSpecificDate(date, TYPE.OPPAI); } if (milkVolume) { records.appendJournalRecordWithSpecificDate(date, TYPE.MILK, milkVolume); } }
トリガー実行時にこの関数に渡されるイベントオブジェクトeの中には以下のような値が入っている。
{ "values": [ "2018/05/03 21:22:27", "2018/05/03 21:20:00", "おっぱい", "" ], "namedValues": { "日時(空欄の場合は現在の時刻)": [ "2018/05/03 21:20:00" ], "イベント(ミルクを飲ませた場合は「ミルクの量」を入力)": [ "おっぱい" ], "ミルクの量 (mL)": [ "" ], "タイムスタンプ": [ "2018/05/03 21:22:27" ] }, (省略) }
valuesとnamedValuesの両方に入力値が入っている。
どっちを使っても良かったのだが、namedValuesを使ったほうが変更に強そうだったので、namedValuesのキーから必要なものを識別してvalueを取るようなロジックを書いた。
トリガーの設定はスクリプトエディタから編集 > 現在のプロジェクトのトリガー
とするとモーダルが開くので、下記のように指定する。
これでGoogle Formsから送信するとrecordsシートに情報が溜まっていくようになった。
onFormSubmitの全体はGitHubを参照 github.com
Google Formsにデフォルト値を入れられるようにする
ここまで作って動くようになったものの、妻から新しい要件が出てきた。
日付と時刻の入力はデフォルト値で現在時刻が埋まっている状態から時刻だけちょちょっと変える感じでしたいらしい。
調べてみるととりあえず初期値を入れる方法はあるらしい。 blog.nakachon.com
ただし、現在時刻のように動的に初期値を作る機能はGoogle Formsにはなさそう。
そこで、とりあえず日付と時刻に適当な初期値が入るようなurlを作成した上で、そのurlをもとにSpreadsheet上で動的に現在時刻が入るurlを作りつつそのurlへのリンクを表示し、「登録はこちらから」みたいな感じにしてみた。
まとめ
これでGoogle SpreadsheetとGoogle FormsとAmazon Echoで育児記録を管理する仕組みができた。
全体像はこんな感じ
機能的には出来ているが、Amazon Echoから操作した際の応答が若干遅いという問題が未解決のまま残っているので、もし解決できたら記事を書く予定。
Amazon echoとGoogle Spreadsheetで育児記録をつける
背景
先日第一子が産まれ、妻がスマホアプリでうんちやらおっぱいやらの記録をつけていたのだが、下記のような問題があった。
- 妻以外の人(僕とか)は記録が見れない
- 妻以外の人(僕とか)がおむつを変えたりミルクを上げたりしたときに記録がつけられない
- スマホが手元にないと記録がつけられない(妻が授乳しながらスマホ取ってと言ってくること多数)
そこで、Amazon echo dotで記録をつけ、記録自体はGoogle Spreadsheetに集約することで上記の問題を解決してみた。
Google Spreadsheetはデータストアとフロントを兼ねてくれるのでかなり楽ちん(なはず)です。
ちなみに、かなりDIY感があって移植性皆無なやり方なのでアプリにして一般公開とか出来なさそう。。
要件
妻にヒアリングしたところ、下記のようなことが必要らしかった。
- うんち、おしっこ、おっぱい、ミルクの時刻を記録して後から参照できる
- ミルクに関してはあげた量も記録できる
- 前回のうんち、おしっこ、おっぱい、ミルクから何時間経過したか簡単に参照できる
- 一日あたりのうんち、おしっこ、おっぱい、ミルクの回数およびミルクの量を簡単に参照できる
ちなみに、一般的なアプリだとついている下記の機能は我が家には不要らしかったので作っていない。
- 左右のおっぱいの区別およびそれぞれの授乳時間の記録
- 寝た・起きた の記録
実装
全体の構成
全体の構成はこんな感じ。これは配置図というらしい。
図の中にはGoogle Formsがあるが、この記事では対象としない。
処理の流れはこんな感じ
- Alexaのカスタムスキルからは標準に従ってAWS Lambdaの関数を使う。
- Google Spreadsheetの操作はGoogle Apps Scriptを使う。GASをかまさずにLambdaから直接spreadsheetのAPIを叩くことも出来たが、Spreadsheetの操作の実装はGoogle側に寄せてAmazon側はそれを叩くだけという構成の方がAlexa以外の他のインターフェイスを作りたくなったときなどに便利なため。
実装
Alexaカスタムスキルの作成
基本的な作成方法、Lambdaとの連携方法は下記の公式チュートリアルを見ればだいたい分かる。 developer.amazon.com
ざっくりと概念を説明すると、
- スキルというのがAlexaにおけるアプリに相当する概念で、スキルの中にインテント(関数に相当)、インテントの中にスロット(関数の引数に相当)という概念がある。
- Amazon Echoへの指示は、「アレクサ、
スキルの呼び出し名
でインテントの発話
」、例えば「アレクサ、育児ノート
でうんちを記録
して」というように行う。 - インテントの識別はサンプル発話をたくさん書くとそれを元にうまいことやってくれる。(うんち、うんこ、うんこ記録、うんちを記録して みたいな感じ笑)
- 話した内容からスロットの値を識別するときにはスロットタイプ(引数型のようなイメージ)を数値にしておけば数値として聞き取るというような動きになる。
この部分は本当にチュートリアルの通りにやれば出来てしまうので割愛。定義をGitHubにあげてあるのでそれを貼っておく。 github.com
AWS Lambdaの関数を作成してとりあえずカスタムスキルを動かす
まずは、チュートリアルにある通りにalexa-skill-kit-sdk-factskill
をベースにbabyNote
のような名前で関数を作成し、作ったスキルと関数を紐付ける。
developer.amazon.com
いったん、下記のような感じでindex.jsを書いたらAmazon Echoに対するコマンドによってLambdaの関数が動いて関数の中で作られた応答がEchoから返ってくることが確認できるようになる。
"use strict"; const Alexa = require('alexa-sdk'); // Alexa SDKの読み込み const gasAccessor = require('./gas-accessor'); const handlers = { // インテントに紐付かないリクエスト 'LaunchRequest': function () { console.log('Processing LaunchRequest'); this.emit('AMAZON.HelpIntent'); }, // スキルの使い方を尋ねるインテント 'AMAZON.HelpIntent': function () { console.log('Processing HelpIntent'); this.emit(':ask', 'うんち、おしっこ、おっぱい、ミルクが記録できます。何をしますか?'); }, 'RegisterUnchiIntent': function () { console.log('Processing RegisterUnchiIntent'); this.emit(':tell', 'うんちを記録しました。'); } }; // Lambda関数のメイン処理 exports.handler = function (event, context, callback) { var alexa = Alexa.handler(event, context); // Alexa SDKのインスタンス生成 alexa.appId = process.env.APP_ID; alexa.registerHandlers(handlers); // ハンドラの登録 alexa.execute(); // インスタンスの実行 };
const handlers
の中に個々のインテントに対するハンドラーを記述する。
this.emit
でAlexaに喋らせることができる。第一引数が:tell
の場合は単に返答するだけ、:ask
の場合はもう一度ユーザの回答を受付け、その回答を元に再度インテントを解釈してハンドラーが動くというような流れになる。
詳しくは第三回のチュートリアルを参照。
Alexaスキル開発トレーニングシリーズ 第3回 音声ユーザーインターフェースの設計 : Alexa Blogs
Alexaコンソールの「テスト」タブを開き、テストを有効にすることでAlexaシミュレータおよび本物のEchoデバイスでスキルを試すことが出来る。
「アレクサ、育児ノートでうんちを記録して」のように話しかけると「うんちを記録しました」と返ってくるはず。
ここまででAlexaに特有な部分はクリアできたので、中身の実装に入っていく。
Google Apps Scriptの作成
Spreadsheetの作成
まずはGoogle Spreadsheetを作成する。
こんな感じでデータを記録するシートを作成し、シート名をrecords
とした。このシートが永続化層。
図はデータが挿入されたあとのもの。
また、プレゼンテーション層もSpreadsheetで作成する。スマホアプリのSpreadsheetで見やすいように縦長に作り、シート名はdashboard
とした。すごく手軽!
- 左のスクリーンショット部分のデータは
records
の情報をもとにGoogle Apps Scriptで動的に作っている。
イベントの登録
っていうのはAlexaではなくGoogle Formsで情報登録できる画面へのリンクで、これについては別の記事で書きます。 - 右のスクリーンショットの部分はSpreadsheetの標準機能の関数とグラフで作っている。
回数のカウントは=COUNTIFS(records!$A:$A, YEAR($B63)&"/"&MONTH($B63)&"/"&DAY($B63), records!$C:$C, "="&C$62)
みたいな感じ。
Google Apps Scriptの作成
作成したSpreadsheetの上部メニューバーからスクリプト エディタを選択することでGoogle Apps Scriptのプロジェクトが作成される。
Google Apps Scriptはプロジェクトというのが実行環境の一単位となっており、プロジェクトを特定のコンテナ(今回で言うとさっき作成したSpreadsheet)に紐付けるとExcel VBAのような使い方ができるようになるらしい。
Spreadsheetからスクリプト エディタを選択するだけでプロジェクトの作成およびSpreadsheetとの紐付けを自動でやってくれる。
プロジェクト名はbaby-note
など、適当に設定しておく。
このスクリプト エディタを使ってソースコードを書いていく。とりあえず基本的なことを書いておく。
- スクリプトファイルの拡張子は
gs
。ほとんど(node.jsとかではなく)素のJavascriptで、SpreadsheetAppのようなコンテナを操作するためのクラスが用意されているものというざっくりした認識 - 複数のgsファイルを作ることが出来るが、特に
require
的なことをしなくても全部のファイルが読み込まれ、その中で定義した関数や変数はすべてグローバルスコープとなる模様 - 名前空間を切って関数や変数を整理することはできる(というかほぼ必須である)が、
records.getRecords()
のようなグローバルスコープでない関数は外部からAPI経由で叩いたり何らかのトリガーで実行したりすることはできず、そういうことがしたい場合はグローバルスコープでfunction getRecords()
のように宣言する必要がある。
recordsに対するデータ追加
recordsシートに対するCRUDはrecords.gsというファイルを作ってまとめた。また、内部でしか使用しないものはrecordsという名前空間を切ってまとめた。そのうちの一部がこれ。
var records = {}; records.getSheet = function () { if (!records.sheet) { records.sheet = SpreadsheetApp.getActive().getSheetByName('records'); } return records.sheet; } records.appendJournalRecord = function (type, opt_parameter) { var startTime = Date.now(); var date = new Date(); var row = []; row.push("'" + date.toLocaleDateString()); row.push("'" + date.toLocaleTimeString().replace(/[^:0-9]/g, '')); row.push(TYPE_NAME[type]); if (opt_parameter) { row.push(opt_parameter); } records.getSheet().appendRow(row); var executionTime = Date.now() - startTime; Logger.log('appendJournalRecordWithSpecificDate took ' + executionTime + ' ms'); };
Spreadsheetに対する単純なデータの追加、読み出しはSpreadsheetApp.getActive().getSheetByName('records')
のようにして取得したSheetオブジェクトに対してappendRow、getValueすることで行う。
基本的なことはここが分かりやすかった。
qiita.com
そして、外部から使用できる関数を下記のように作成する。
function registerUnchi() { var startTime = Date.now(); records.appendJournalRecord(TYPE.UNCHI); // 新しいレコードを追加 var values = {unchiCount: records.countRecords(TYPE.UNCHI, new Date())}; // 今日のうんちの回数をカウントしてオブジェクトに詰める Logger.log('registerUnchi : ' + JSON.stringify(values)); var executionTime = Date.now() - startTime; values.executionTime = executionTime; Logger.log('registerUnchi took ' + executionTime + ' ms'); return values; // 今日のうんちの回数が入ったオブジェクトを返す }
普通にオブジェクトを作ってreturnすることでAPIのレスポンスにデータを入れることができる。
今回はうんちを登録し、本日何回目のうんちかという情報を返すようにした。
これで作った関数をテストできる。
画面上部で関数名を選択して実行ボタンをクリックするとrecordsシートに行が追加される。初回のみSpreadsheetへのアクセスの許可が必要になる。
実行後に表示 > ログ
をクリックするとLoggerで出力したログを見ることができる。今のところこれが最も有力なデバッグ方法。
dashboard用の処理の話も書こうかと思ったけど長くなるので割愛。
作ったGoogle Apps Scriptの全量はこちら
github.com
AWS LambdaからGoogle Apps Scriptを叩くための設定と実装
Google Apps Scriptの関数を外部から叩ける用にする設定
これが結構ややこしかった。
とりあえずここに書いてある通りにすればだいたい行けるはず。
qiita.com
OAuth 2.0 Playgroundでトークン取得後にAPIを試してみる時のrequest bodyは下記のようにする。
{ "function": "registerUnchi", "parameters": [], "devMode": false }
devModeはtrueにしたほうが便利なのは間違いないのだが、下記で報告されている問題にぶち当たってしまい、かなりハマった。
原因は不明だが、devModeをtrueにしていてAPIを叩くと404が返ってくる場合にはfalseにするのが手っ取り早い。
stackoverflow.com
Lambda側でGASのAPIを叩く処理を実装する
ここから先はLambdaのインラインコード編集ではなく、アクション > 関数のエクスポート
で落としてきたソースコードをローカルで編集する。
npmを使いたいのと、ライブラリを組み込んだ結果サイズが増えすぎてインラインコード編集ができなくなってしまったため。
まずはターミナルを開き、ダウンロードしたソースコードのルートディレクトリでnpm install googleapis@25.* --save
を実行してgoogleのapiを叩くためのライブラリを入れる。
そして、下記のようにGASのライブラリを叩くモジュールgas-accessor
を実装する。
const google = require('googleapis'); const OAuth2 = google.auth.OAuth2; const CLIENT_ID = process.env['CLIENT_ID']; const CLIENT_SECRET = process.env['CLIENT_SECRET']; const ACCESS_TOKEN = process.env['ACCESS_TOKEN']; const REFRESH_TOKEN = process.env['REFRESH_TOKEN']; const SCRIPT_ID = process.env['SCRIPT_ID']; const DEV_MODE = process.env['DEV_MODE'] ? /^true$/i.test(process.env['DEV_MODE']) : false; const gasAccessor = {}; gasAccessor.executeFunction = function (functionName, callback, opt_parameter) { var startTime = Date.now(); console.log('executeFunction started [functionName=' + functionName + ', parameter=' + opt_parameter); const auth = new OAuth2(CLIENT_ID, CLIENT_SECRET); auth.setCredentials({ access_token: ACCESS_TOKEN, refresh_token: REFRESH_TOKEN }); const script = google.script('v1'); script.scripts.run({ auth: auth, scriptId: SCRIPT_ID, resource: { function: functionName, parameters: [opt_parameter], devMode: DEV_MODE } }, (err, result) => { var turnAroundTime = Date.now() - startTime; console.log(functionName + ' API execution took ' + turnAroundTime + ' ms'); if (err) { console.error(err); } else { console.log(result.data.response.result); callback(result.data.response.result); var callbackExecutionTime = Date.now() - startTime - turnAroundTime; console.log('callback execution took ' + callbackExecutionTime + ' ms'); } }); }; module.exports = gasAccessor;
認証系の情報はLambdaの環境変数として受け取るようにしている。
また、executeFunctionではGASの関数名とcallback関数を引数で受け取ってGASの関数の処理が帰ってきたら任意のcallback処理を実行できるようにしてある。
GASの関数の返り値はresult.data.response.result
に入っており、これをcallback関数に渡すようにしてある。
これをindex.jsでこのように使う。
const gasAccessor = require('./gas-accessor'); const handlers = { 'RegisterUnchiIntent': function () { console.log('Processing RegisterUnchiIntent'); gasAccessor.executeFunction('registerUnchi', function (result) { this.emit(':tell', '本日' + result.unchiCount + '回目のうんちです'); }.bind(this)); }
これでregisterUnchi
をAPI経由で実行して、registerUnchi
から返ってきたうんち回数を含むメッセージをAlexaに渡すことができる。
index.jsが存在する階層をzipで固めてLambdaにアップロードすることでLambdaのソースコードを更新できる。
その際、zipには親のフォルダを含めないよう注意する。
stackoverflow.com
自分はいちいち気を使ってzip作るのめんどいのでシェルスクリプトを書いた。
github.com
Lambda上ではこのように環境変数を設定する。
これで、Alexaコンソールのテストで「育児ノートでうんちを記録」と入れると「本日○回目のうんちです」と返ってくるはず。
返ってこなかったときはGASのスクリプトエディタやLambdaで原因を調査する。
ちなみに、Lambdaではテストイベントというのを作ってテストをすることができる。
Alexaコンソールに表示されるこいつを
Lambdaのここから入力してテストボタンを押せばAlexaを介すことなくテストができて便利。
まとめ
とりあえず、これでAmazon echo dotとGoogle Spreadsheetで育児記録をつけられるようになった。
ただ、今は1ヶ月検診がまだなのでずっと家にいるから常にAmazon echo dotが使えるが、出先でおむつ交換したときにどうするかという課題がある。
何回かすでに言及したが、それについてはGoogle Formを使って解決してみたので後日まとめます。
回線速度をZabbixでモニターする
背景
最近、自宅のケーブルテレビ回線を増速しました。ただ、ネットで調べると増速しても回線品質が悪いとなかなかの評判なので、実効速度をモニターしてあまりに遅いようなら光回線とか検討しようかなーと思い、Zabbixでダウンロードの速度を計測することにしました。
自宅のマンションが無料で8Mbpsのケーブルテレビ回線を使えるから使っていたんですが、8Mbpsだと下りは1〜7.5Mbps、上りは100kbpsぐらいで結構ストレスがたまります。2000円弱で30Mbpsにできるそうなので試してみようかなと。その上は4000円弱で160Mbpsだそうで、それなら光にした方がいいかなーというところです。
Zabbixで回線速度をモニターする
外部チェックスクリプトの作成
curl -w "%{speed_download}"
#!/bin/bash SINGLE_DL_TIMEOUT=7 ZABBIX_TIMEOUT=30 FILES_TO_DOWNLOAD=() FILES_TO_DOWNLOAD+=(http://www.gomplayer.jp/img/sample/mp4_h264_aac.mp4) FILES_TO_DOWNLOAD+=(http://www.gomplayer.jp/img/sample/mp4_mpeg4_aac.mp4) FILES_TO_DOWNLOAD+=(http://ec.nikkeibp.co.jp/nsp/dl/05405/05405_08.zip) FILES_TO_DOWNLOAD+=(http://ec.nikkeibp.co.jp/nsp/dl/05405/05405_13.zip) FILES_TO_DOWNLOAD+=(http://wwwjp.kodak.com/JP/images/ja/digital/cameras/dc120/image031.tif) FILES_TO_DOWNLOAD+=(http://www.sharp.co.jp/galileo/guide/movie/sample/sample2_k.mpg) FILES_TO_DOWNLOAD+=(http://homepage1.nifty.com/trust-system/SAMPLE_koriyama.LZH) ############################ cnt=0 sum=0 dl_num=$(expr \( ${ZABBIX_TIMEOUT} - 1 \) / ${SINGLE_DL_TIMEOUT}) function calc { if [ ${cnt} -gt 0 ]; then expr ${sum} / ${cnt} 2>/dev/null else echo 0 fi exit } trap calc EXIT files_to_process=() while [ ${#files_to_process[@]} -lt ${dl_num} ] && [ ${#files_to_process[@]} -lt ${#FILES_TO_DOWNLOAD[@]} ]; do index=$((RANDOM % ${#FILES_TO_DOWNLOAD[@]})) file=${FILES_TO_DOWNLOAD[${index}]} echo "${files_to_process[@]}" | grep "${file}" > /dev/null if [ $? -eq 0 ]; then continue; fi files_to_process+=(${file}) done for file in ${files_to_process[@]}; do speed_Bps=$(curl -kL -m ${SINGLE_DL_TIMEOUT} "${file}" -o /dev/null -w "%{speed_download}" 2>/dev/null) speed_bps=$(expr ${speed_Bps%.*} \* 8) if [ $speed_bps -gt 0 ]; then sum=$(expr ${sum} + ${speed_bps}) cnt=$(expr ${cnt} + 1) fi done
Zabbixでアイテムを設定