背景
僕はつい意味もなく冷蔵庫を開けてしまう癖がありますものの数十分で冷蔵庫の中に新しいものが湧くわけでもないのに。でもなんだか開けてしまうんですよね(笑)
でも、ちまちま不必要に冷蔵庫を開けてしまうと、電気代のことや環境のことなどを考えると良くありません。また、5人家族の我が家では1日にどれぐらい冷蔵庫が開け閉めされ、また開いている時間は合計でどれぐらいになるのか興味を持ちました。
冷蔵庫の開け閉めの回数と開いている時間が可視化できれば、僕の冷蔵庫を不必要に開けてしまう癖も少しはおさまるかもしれないと思って開発することにしました。
電子工作も全然やったことないし、今回提供してくださったobnizとたまたま家に転がっていた距離センサーをくっつけてみたら意外とうまく値が取得できたのでこれの組み合わせを使うことにしました。(ちなみにブレットボードを今回の作品で初めて触りました。)
作っているうちに自分の得意分野で拡張していったら、最終的にはLINEBotが完成し、冷蔵庫の情報と一緒に我が家に不足しているものを登録できる機能も開発しました。詳細については後ほど詳しく解説します。
このプロダクトのポイント
・2021/5/13についにパブリックベータ版になったnotionAPIをフル活用している
・距離センサーを用いており、冷蔵庫だけでなく汎用性が高い
・フロントエンド・サーバーサイド・電子工作に触れることができる
・冷蔵庫の開き時間が少なくなる
センサー
DEMO
システム構成図
部品
品名 | 用途 | 価格 |
---|---|---|
obniz Board 1Y | メインコンピュータ | 6930 |
ブレッドボードx2 | センサーの回路 | 200x2 |
ジャンプワイヤ | センサーの回路 | 990 |
モバイルバッテリー(給電できる場合はいらない) | センサーの回路 | 3000弱 |
ソースコード
obniz自体を動かしているnodejsのソースコードは以下です。
index.js
"use strict"; require("dotenv").config(); const Obniz = require("obniz"); const axios = require("axios"); const obniz = new Obniz(process.env.OBNIZ_ID); obniz.onconnect = async () => { const hcsr04 = obniz.wired("HC-SR04", { gnd: 0, echo: 1, trigger: 2, vcc: 3, }); let isOpen = false; let startTime; let totalTime; while (true) { let avg = 0; let count = 0; for (let i = 0; i < 3; i++) { // measure three time. and calculate average const val = await hcsr04.measureWait(); if (val) { count++; avg += val; } } if (count > 1) { avg /= count; } console.log(avg, isOpen); if (avg > 100 && isOpen === false) { isOpen = true; startTime = new Date().getTime(); console.log("冷蔵庫があきました!"); } if (avg < 100 && isOpen === true) { //しまった時 isOpen = false; console.log(new Date().getTime()); console.log(startTime); totalTime = new Date().getTime() - startTime; totalTime = totalTime / 1000; const hour = Math.floor(totalTime / 3600); const hour_wari = Math.floor(totalTime % 3600); const min = Math.floor(hour_wari / 60); const min_wari = Math.floor(hour_wari % 60); const sec = min_wari; console.log(`${hour}時間${min}分${sec}秒開いていた`); //totalTimeをAPIに送る axios.post(process.env.API_URL + "/register", { totalTime: Math.floor(totalTime), }); } await obniz.wait(100); } };
ハード以外のAPIやLINEBotのソースコードなどはこちらのリポジトリに公開されています
開発過程
・NotionAPIの学習
・ハード開発
・API開発
・LINEBot開発
・設置・検証
・サーバーに移行し永続化
の順に工夫したポイント・難しいところをまとめていきます
NotionAPIの学習
NotionAPIは3日ほど前に出たばかりで、全くと言っていいほど知見も記事もありませんでした。ただひたすらに公式の英語ドキュメントを読んで理解しました。最低限の書き出しや特殊な概念(Notion特有の概念)を理解してAPIで操作し、現時点でできることできないことを把握するのに結構時間を取られました。
APIの習得する過程はこちらの記事にてまとめてあります。
ここではこのプロダクトに利用できた知見のみ共有します。
まず、NotionAPIはJavaScriptのSDKが公開されています。こちらを使うことで簡単にAPIを利用することができます。
今回使ったAPIはDBから値を取得するものと値を更新するものです。とりあえずこれが使えたらDBとして使うことができるので何かものが作れるかと思います。
nodejsでnpmモジュールを用いてSDKを利用するには以下のように呼び出します
index.js
const { Client } = require("@notionhq/client"); // Initializing a client const notion = new Client({ auth: process.env.NOTION_TOKEN, });
データを取得したい場合は以下のようにリクエストを送ります。今回はクエリのAPIを利用していますが、全項目(回数と時間)を取得しているのでクエリ式を入れておりません。
index.js
//今のセンサーのあたいを取得しにいく const request_payload = { path: "databases/" + "データベースのID(databaseId)" + "/query", method: "POST", }; const current_pages = await notion.request(request_payload); console.log(current_pages.results)
データを書き込みたい時は以下のようにリクエストを送ります。
index.js
const = todayCount = { id : "データベースの項目のID(pageId)", count : 2 //更新したい値を入力する } const request_payload = { path: "pages/" + todayCount.id, method: "patch", body: { parent: { database_id: "データベースのID(databaseId)", }, properties: { 冷蔵庫: { title: { text: { content: "本日開いた回数", }, }, ], }, count: { number: todayCount.count, }, id: { rich_text: [ { text: { content: todayCount.id, }, }, ], }, }, }, }; await notion.request(request_payload);
このような記述で値の更新を行います。NotionAPIは他にもアクセスしているユーザーの情報を取得したり、項目の新規追加(更新では無い)のようなことも、クエリやソートも柔軟にできるようです。しっかり使いこなすことでもっといろいろな機能を実装することができます。ただ、まだ対応していないブロックなどもあり、パブリックベータ(記事執筆時点)であることを忘れずに使う必要があると感じました。
ハードウェアの開発
初めてobnizに電源を入れ、距離センサーなどを繋いだ時のまとめはこちらに書かれています。こちらを参考にし、上記で書いたobnizと距離センサーを接続し、電源を入れインターネットに繋ぐセットアップを行いました。初めはソースコードの開発のところに記したサンプルコードを起動すると簡単に動いたので、特に開発面で難しいことはないと思います。
①obnizとジャンプワイヤーとブレッドボードと距離センサーを準備
②公式ドキュメントを参考につなげる
③ソースコードを起動
④動いた
API開発
obnizから取得し、更新された値をNotionのデータベースに書き込みに行くAPIです。obnizから直接書き込みに行っても良かったのですが、他の役割を持つAPI(日が変わるとデータを初期化する)も実装しないといけないので作成しました。構成図に書いた通り、AWSのLambdaくを用いて開発を行いました。ソースコードはこちらです。
index.js
//Notionに記録するAPI //Lambda関数 const { Client } = require("@notionhq/client"); const axios = require("axios"); const querystring = require("querystring"); // Initializing a client const notion = new Client({ auth: process.env.NOTION_TOKEN, }); exports.handler = async (e) => { console.log(e.path); console.log(e.body); const event = e.body; let message = ""; switch (e.path) { case "/register": message = "ok"; await registerFunc(JSON.parse(event)); break; case "/cron": await cronFunc(); break; } const response = { statusCode: 200, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", }, body: JSON.stringify(message), }; return response; }; async function registerFunc(event) { const request_payload = { path: "databases/" + "fb9bd269-8888-4fa1-a07a-83817ce9ffb9" + "/query", method: "POST", }; const current_pages = await notion.request(request_payload); let todayCount = {}; let todayTime = {}; for (const page of current_pages.results) { if (page.id === "07ce4234-d2d3-43be-ba8d-18be7bec2b58") { todayCount = { id: page.id, count: page.properties.count.number }; } if (page.id === "9217cbb3-0a20-4fc0-9778-31d0002f200c") { todayTime = { id: page.id, count: page.properties.count.number }; } } todayCount.count = todayCount.count + 1; todayTime.count = todayTime.count + event.totalTime; await notionUpdateCount(todayCount, todayTime); } async function notionUpdateCount(todayCount, todayTime) { const request_payload = { path: "pages/" + todayCount.id, method: "patch", body: { parent: { database_id: "fb9bd269-8888-4fa1-a07a-83817ce9ffb9fb9bd269-8888-4fa1-a07a-83817ce9ffb9", }, properties: { 冷蔵庫: { title: [ { text: { content: "本日開いた回数", }, }, ], }, count: { number: todayCount.count, }, id: { rich_text: [ { text: { content: todayCount.id, }, }, ], }, }, }, }; await notion.request(request_payload); const request_payload2 = { path: "pages/" + todayTime.id, method: "patch", body: { parent: { database_id: "fb9bd269-8888-4fa1-a07a-83817ce9ffb9fb9bd269-8888-4fa1-a07a-83817ce9ffb9", }, properties: { 冷蔵庫: { title: [ { text: { content: "本日開いた時間", }, }, ], }, count: { number: todayTime.count, }, id: { rich_text: [ { text: { content: todayTime.id, }, }, ], }, }, }, }; await notion.request(request_payload2); } async function cronFunc() { //今のセンサーのあたいを取得しにいく const request_payload = { path: "databases/" + "fb9bd269-8888-4fa1-a07a-83817ce9ffb9" + "/query", method: "POST", }; const current_pages = await notion.request(request_payload); let todayCount = {}; let todayTime = {}; for (const page of current_pages.results) { if (page.id === "07ce4234-d2d3-43be-ba8d-18be7bec2b58") { todayCount = { id: page.id, count: page.properties.count.number }; } if (page.id === "9217cbb3-0a20-4fc0-9778-31d0002f200c") { todayTime = { id: page.id, count: page.properties.count.number }; } } //今家で必要なものを取得しにいく const request_payload2 = { path: "databases/" + "c98d166221914e1a92f4e7a90761c0da" + "/query", method: "POST", body: { filter: { property: `want`, checkbox: { equals: true, }, }, }, }; const current_pages2 = await notion.request(request_payload2); let wantItems = "\n【現在この家に必要なものリスト】"; for (const item of current_pages2.results) { wantItems = wantItems + "\n" + "・" + item.properties.item.title[0].plain_text; } await axios.post( "https://notify-api.line.me/api/notify", querystring.stringify( { message: `本日は冷蔵庫が!${todayCount.count}回開けられ、開いていた時間は${todayTime.count}秒でした。明日はもっと短くなるように心がけましょう。`, }, ), { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Bearer ${process.env.NOTIFY_TOKEN}`, }, } ); await axios.post( "https://notify-api.line.me/api/notify", querystring.stringify( { message: `${wantItems}\n編集:https://lin.ee/PgZiEkV`, }, ), { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Bearer ${process.env.NOTIFY_TOKEN}`, }, } ); todayCount.count = 0; todayTime.count = 0; await notionUpdateCount(todayCount, todayTime); //LINEに投げる }
日が変わるとcronを回してデータの初期化LINENotifyを使って報告を行うようにしました。
cronnの設定は調べると沢山出てきますが、毎日24じになったら起動するというルールは以下のように定義することでいけます。
今回はGMT基準で動かしているので時差計算をして15じになったら起動するようにしています。
LINEBot開発
LINEBotを"作る"過程は以前詳しく記事を書いて言及しておりますのでこちら「LINEBotをみんなで作ろう〜環境構築編〜」をご覧ください。
LINEBotを作った理由は、Notionのデータベースをもっと活用したかったからです。毎日の冷蔵庫の結果報告と同時に、家族が今家に欲しいものを登録していると、通知してくれるシステムがあるといいなと思いました。例えば「卵がなくなったし明日買いに行かないと!」とか「ティッシュがもうすぐなくなるわー」とか。家族の誰かが必要としているけれど誰かがまとめて買いに行けばいい。みたいなニーズ。買い物に行ったけど、そんなの買ってこないといけないなんて知らなかった。みたいなことを解決したいなと思いました。
index.js
//LINEBot //Lambda関数 "use strict"; // モジュール呼び出し const crypto = require("crypto"); const line = require("@line/bot-sdk"); const { Client } = require("@notionhq/client"); const notion = new Client({ auth: process.env.NOTION_TOKEN, }); // インスタンス生成 const lineClient = new line.Client({ channelAccessToken: process.env.ACCESSTOKEN, }); exports.handler = (event) => { let signature = crypto .createHmac("sha256", process.env.CHANNELSECRET) .update(event.body) .digest("base64"); let checkHeader = (event.headers || {})["X-Line-Signature"]; if (!checkHeader) { checkHeader = (event.headers || {})["x-line-signature"]; } const body = JSON.parse(event.body); const events = body.events; console.log(events); // 署名検証が成功した場合 if (signature === checkHeader) { events.forEach(async (event) => { let message; switch (event.type) { case "message": message = await messageFunc(event); break; case "postback": message = await postbackFunc(event); break; case "follow": message = { type: "text", text: "追加ありがとうございます!" }; break; } // メッセージを返信 if (message != undefined) { await sendFunc(body.events[0].replyToken, message); // .then(console.log) // .catch(console.log); return; } }); } // 署名検証に失敗した場合 else { console.log("署名認証エラー"); } }; async function sendFunc(replyToken, mes) { const result = new Promise(function (resolve, reject) { lineClient.replyMessage(replyToken, mes).then((response) => { resolve("送信完了"); }); }); return result; } async function messageFunc(event) { let message = ""; message = { type: "text", text: `メッセージイベント` }; let headerMes = event.message.text.split("/"); if (event.message.text === "買いました報告") { //現在wantになっている商品をカルーセルで一覧にする const request_payload = { path: "databases/" + "データベースID" + "/query", method: "POST", body: { filter: { property: `want`, checkbox: { equals: true, }, }, }, }; const current_pages = await notion.request(request_payload); const wantItemsArry = []; for (const item of current_pages.results) { wantItemsArry.push({ type: "bubble", direction: "ltr", header: { type: "box", layout: "vertical", contents: [ { type: "text", text: `${item.properties.item.title[0].plain_text}`, weight: "bold", size: "xxl", color: "#626C62FF", align: "center", wrap: true, contents: [], }, { type: "separator", }, ], }, footer: { type: "box", layout: "horizontal", contents: [ { type: "button", action: { type: "postback", label: "購入した", data: `${item.properties.item.title[0].plain_text}/${item.id}`, }, style: "primary", }, ], }, }); } message = { type: "flex", altText: "現在買って欲しいリストです", contents: { type: "carousel", contents: wantItemsArry, }, }; //ポストバックに 項目名/id } else if (event.message.text === "欲しいです報告") { } else if (headerMes.length === 2) { if (headerMes[0] === "追加") message = { type: "text", text: `${headerMes[1]}を追加しました` }; // notionに追加しにいく await notion.request({ path: "pages", method: "POST", body: { parent: { database_id: "データベースID" }, properties: { item: { title: [ { text: { content: headerMes[1], }, }, ], }, want: { checkbox: true, }, }, }, }); } else { message = { type: "text", text: "「買いました報告」か「欲しいです報告」のどちらかを送ってください。", }; } return message; } const postbackFunc = async function (event) { let message = ""; message = { type: "text", text: "ポストバックイベント" }; const headerData = event.postback.data.split("/"); message = { type: "text", text: `${headerData[0]}の購入を保存しました!` }; const request_payload = { path: "pages/" + headerData[1], method: "patch", body: { parent: { database_id: "データベースID" }, properties: { item: { title: [ { text: { content: headerData[0], }, }, ], }, want: { checkbox: false, }, }, }, }; await notion.request(request_payload); return message; };
LINEBotは作成したものの、アイテムを追加する時に追加/{{欲しいもの}}
と入力しなければいけず、ユーザーにとって不便だなと思ったのでLIFFを使って簡単に登録できるようにしました。LIFFというは上記DEMO動画のLINEの画面で表示される下からうにゅっと出てくるフロント画面です。LINE Front-end Frameworkていって、自分の作ったサイトとかをLINE上でめっちゃいい感じにしてくれるやつです。今回はフロントエンドをnuxtで作成し、netlifyにデプロイしてあります。入力した値を取得し、登録ボタンが押したらトーク上に「追加/{{入力された値}}」を変わりに送信して画面を閉じてくれます。UX爆上がりですね
vueファイルのコードにはなりますが参考までに使ってみてください。
index.vue
<template> <div> <v-row justify="center" style="margin-top: 50px"> <div class="mt-6" align="center"> <h3>欲しいものをリスト追加する</h3> <v-text-field label="商品名" v-model="item" style="margin-top: 40px" solo > </v-text-field> <v-btn color="success" elevation="3" @click="register" large >追加</v-btn > </div> </v-row> </div> </template> <script> export default { data: () => ({ item: "", }), async mounted() { liff .init({ liffId: "1655989367-pax2zN0P", }) .then(() => { this.isLoggedIn = liff.isLoggedIn(); if (!liff.isInClient() && !liff.isLoggedIn()) { liff.login(); } }); }, methods: { register() { liff .sendMessages([ { type: "text", text: `追加/${this.item}`, }, ]) .then(() => { console.log("message sent"); liff.closeWindow(); }) .catch((err) => { console.log("error", err); }); }, }, }; </script>
設置・検証
上記の検証でいい感じの計測ができています。検証成功です!
サーバーに移行し永続化
僕のパソコンでずっとスクリプトを動かしておくわけにはいかないので、常時起動できるサーバーを探しました。個人てきに以前から興味があったAWS Lightsailを使ってみました。月額400円ぐらいで簡単なサーバーを固定金額で借りられるのはいいですね。(それほどいいサーバーではないが)
ずっとコードを動かし続けることに関しての知見はあまり少ないのですが、すこい調べるとforeever
コマンドというものを利用するとずっと動かすことができるそうなのでインストールしてやってみました。すると無事常時起動で動かすことができました
foreever
コマンドの利用の仕方はこちらの記事を参考にし、$ forever -w indecx.js
で起動しました。止める時はforever stop index.js
で良いそうです!この辺りはまだよくわかっていないのでこれから勉強していきます。
最後に
とっても楽しかったです。
結構時間なくて締め切り前日に徹夜ハッカソンして開発したのはいい思い出です!
-
inoue2002
さんが
2021/05/16
に
編集
をしました。
(メッセージ: 初版)
-
inoue2002
さんが
2021/05/16
に
編集
をしました。
-
inoue2002
さんが
2021/05/16
に
編集
をしました。
-
inoue2002
さんが
2021/05/16
に
編集
をしました。
-
inoue2002
さんが
2021/05/16
に
編集
をしました。
ログインしてコメントを投稿する