hrendohのアイコン画像

obniz Board 1Yと距離センサー VL53L0X 使用した鍵閉め忘れ通知アプリ

hrendoh 2021年05月13日に作成  (2021年05月16日に更新)

obniz Board 1Yと距離センサー VL53L0X 使用した鍵閉め忘れ通知アプリ

概要

obniz IoTコンテスト向けに、obniz Board 1Yと距離センサーを利用した鍵締め忘れ通知システムを作ってみました。
家の鍵が閉まっているかの確認と、閉め忘れていたらスマフォの通知でお知らせしてくれます。

実際、これを実現したいと思ったらQrioなどスマートロック製品を取り付ける方が安価でかんたんな気もしますが、自宅のドアはサムターンが上下2つ付いているタイプなので、スマートロックを導入できませんでした。
とはいえ、鍵の閉め忘れが気になることが多々あるので、この機にobnizで実現することにしました。

デモ動画

こちらが、実際の動きのデモです。スマフォと同時に撮りたかったので、実際に外には出ませんので鍵の閉め忘れを通知するという利用シーンのイメージではなく、あくまでシステムの動作確認のための動画になっています。

ここに動画が表示されます

仕様

鍵の開閉ステータスの判定

ドアの鍵の開閉状態の検出は、以下の様にドアのサムターンに対する距離を計測して距離がしきい値より小さければ鍵が「閉まっている」

キャプションを入力できます

しきい値より大きければ「開いている」と判定します。

キャプションを入力できます

通知を送信する条件

距離の計測は、Firebase functionsから1分間隔で実行し、読み取った値をFirestoreに保存します。

保存した値は、Firebase hostingにホスティングされたWebアプリ (PWA) で確認することができます。

また、3回連続、つまり2〜3分間、鍵が開きっぱなしだった場合、Firebase Cloud Messageを利用してpush通知します。
キャプションを入力できます

Firebaseを含めたシステム構成は以下のようになります。

キャプションを入力できます

省電力化、obnizのスリープと復帰条件

obniz Board 1Yは、スリープ機能を利用して省電力化し電池で動かします。

計測の結果3回連続で閉まっていると判定された場合はIOトリガーを設定してスリープに入り、焦電型赤外線センサー(人感センサー)でサムターンに手をかけたタイミングで起動するようにしています。

焦電型赤外線センサーは常時起動していますが、データシートから消費電力は170μAなので、単3電池1000mAhのもので5000時間以上は持ちます。
加えて、obnizは朝晩2回起動するとして1日5分程度とすると、ざっくりと1000mAhの電池で2ヶ月程度持ちそうです。

焦電型赤外線センサーの取付箇所ですが、ドアに近づいたときだけ反応するようにセンサーはあえて上向きに取り付けます。

キャプションを入力できます

ドアへの固定方法

ドアにobniz Boardなどを収めたケースをどう固定するか?ですが、こちらはコクヨの「ひっつき虫」を使ってくっつけます。

キャプションを入力できます

ひっつき虫はなかなか優秀で、そこそこの重さのものもしっかり固定してくれて、かつ、剥がした後が残りません。
今回の一番の課題は、実は何でobnizをドアに固定するのか?というところだったりするので、ひっつき虫 様様です。

部品

距離センサー VL53L0Xは Pololuのモジュールをスイッチサイエンスから購入しました。その他細かい部品は秋月電子通商で揃います。

部品 購入サイト
距離センサー Pololu VL53L0X モジュール スイッチサイエンス
焦電型赤外線センサー PaPIRsVZ 秋月電子通商
電池ボックス 単3 x 3本 Bスナップ 秋月電子通商
ジャンパーワイヤ付バッテリースナップ 秋月電子通商
ミニブレッドボード 45穴 秋月電子通商
ジャンパーワイヤ(オス-オス) 秋月電子通商
ジャンパーワイヤー(オス-メス) 秋月電子通商
M3 x 6m 皿ネジ x 2 秋月電子通商
M2 x 5mビス 秋月電子通商
コクヨ ひっつき虫 Amazon

設計

配線図

使用する部品の配線は以下のとおりです。

キャプションを入力できます

ピンアサインを整理すると以下のようになります。

obniz VL53L0X PaPIRsVZ 電池
0 - OUT -
1 SDA - -
2 SCL - -
V+ - VDD V+
3.3V VIN - -
V- GND GND V-

配線図後

キャプションを入力できます

焦電型赤外線センサー PaPIRsVZの足は細いのでジャンパーワイヤーの圧着具体に不安が残ります。
小さい基盤にはんだ付けしてモジュール化した方が良いかもしれません。

ケース

今回、部品を納めるためにFusion 360でケースをモデリングしてプリントしてみました。
STLファイルは以下のリンクで公開していますので、再現したい場合はご利用ください。

https://drive.google.com/drive/folders/14HadTAPDQGxzJ8DyDB4cvYHrjYTo_r5F?usp=sharing

キャプションを入力できます

ケース設計のポイントは、この溝にブレッドボードの突起を挿し込んで固定することができます。

キャプションを入力できます

焦電型赤外線センサーを挿し込む丸い穴は、おそらく3Dプリンタできれいに成形できないので、プリント後リーマーなどを使って整える必要があります。
キャプションを入力できます

ソースコード

コードは、すべてFirebase上で動作するようにしています。

以下のコードは、Githubリポジトリで公開しています。

https://github.com/hrendoh/obniz-iot-check-the-lock

Firebaseへのセットアップ手順はリポジトリのREADMEを参照ください。

以下は、ロジックを記述しているコードと説明です。

functions

functionは、1分ごとに実行します。

処理の流れは、おおまかに以下のとおりです。

  1. デバイスobniz Board 1Yへ接続
  2. デバイスがonlineであれば、距離を計測。距離の計測はobniz.jsのパーツライブラリVL53L0Xを利用。
  3. 計測した値をFirestoreに保存
  4. 過去3回の計測結果を取得して、鍵の開閉を判定。
    5-1. 3回連続で閉じていたら、デバイスをIOトリガーでスリープ。 REST APIを利用。
    5-2. 3回連続で空いていたら、Firebase Cloud Messageでpush通知。
  5. obnizの接続をクローズして終了

詳細はソースコード内のコメントを参照ください。

functions/index.js

const functions = require('firebase-functions'); const admin = require('firebase-admin'); const Obniz = require('obniz'); const fetch = require('node-fetch'); // Firebaseの環境変数からデバイスIdとアクセストークンを取得 const { obniz_id, access_token } = functions.config().obniz; admin.initializeApp(); const db = admin.firestore(); const OBNIZ_REST_API_ENDPOINT = `https://obniz.com/obniz/${obniz_id}/api/1`; // REST APIでIOトリガースリープを実行 const sleepIOTrigger = async () => { const opt = { method: 'POST', body: JSON.stringify([ { "system": { "sleep_io_trigger": true } } ]), headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${access_token}` } }; await fetch(OBNIZ_REST_API_ENDPOINT, opt); } // Firestoreに計測データを保存 const storeDistance = async (distance) => { try { console.log('storeDistance', distance); const docRef = await db.collection("logs").add({ distance: distance, timestamp: new Date() }); console.log("Document written with ID: ", docRef.id); } catch (err) { console.log('firestore error:', err); } } // 計測データをPWAにpush送信 const sendDistance = async (distance) => { const tokenSnapshot = await db.collection('tokens').orderBy('timestamp', 'desc').limit(1).get(); if (!tokenSnapshot.empty) { const doc = tokenSnapshot.docs[0]; const data = doc.data(); var payload = { data: { distance: `${distance}` } }; try { const response = await admin.messaging().sendToDevice(data.token, payload); console.log('Successfully sent message:', response); } catch (error) { console.log('Error sending message:', error); } } } // 開きっぱなしをpush通知 const sendNotification = async () => { const tokenSnapshot = await db.collection('tokens').orderBy('timestamp', 'desc').limit(1).get(); if (!tokenSnapshot.empty) { const doc = tokenSnapshot.docs[0]; const data = doc.data(); console.log(data.token, data.timestamp); var payload = { notification: { title: 'ドアが空いています', body: '戸締まりして下さい', icon: 'images/icons/icon-192x192.png', } }; try { const response = await admin.messaging().sendToDevice(data.token, payload); console.log('Successfully sent message:', response); } catch (error) { console.log('Error sending message:', error); } } } // メインロジック 1分ごとに実行 const main = async () => { const obniz = new Obniz(obniz_id, { access_token: access_token }) // 参考: https://qiita.com/wicket/items/8b1acffdd9880e2b3637 const connected = await obniz.connectWait({ timeout: 5 }); if (!connected) { console.log('obniz is offline. skip process.'); await obniz.closeWait(); return; } console.log('Obniz connected!'); try { const sensor = obniz.wired('VL53L0X', { scl: 2, sda: 1 }); const distance = await sensor.getWait(); console.log(distance); await storeDistance(distance); await sendDistance(distance); const snapshot = await db.collection('logs').orderBy('timestamp', 'desc').limit(3).get(); let isGoingSleep = false; if (!snapshot.empty) { // 最近の計測が3回連続で閉じているか? isGoingSleep = snapshot.docs.every((doc) => { const data = doc.data(); console.log(data.distance, data.timestamp); return data.distance > 20 && data.distance < 120; }); } if (isGoingSleep) { // リセットのデータ -1 を保存して、IOトリガーでスリープ console.log('Key is closing, going to sleep.'); await storeDistance(-1); await sleepIOTrigger(); } else { // 3回連続で開いていたらpush通知 const isPushing = snapshot.docs.every((doc) => { const data = doc.data(); console.log(data.distance, data.timestamp); return data.distance === 20 || data.distance > 120; }); console.log('Do push: ' + isPushing); if (isPushing) { sendNotification(); } } } catch (err) { console.log(err); } finally { await obniz.closeWait(); } } exports.scheduledFunction = functions.region('asia-northeast1').pubsub.schedule('every 1 minutes') .onRun(main);

IOトリガーでスリープする箇所ですが、本来obniz.sleepToTriggerを使いたかったところです。ローカルでは動作するのになぜかFirebase functions上では動かず。。仕方がないのでREST APIを呼んでいます。

Cloud Functionなどサーバーレス環境でobnizを実行する方法についてはkidoさんの記事「obnizとサーバーレスを組み合わせるTips」を参考にさせていただきました。

Webアプリ (PWA)

PWA側は、状態の表示とpush通知の許可を求める「index.html」と、push通知を受けるサービスワーカーの「firebase-messaging-sw.js」の2つです。

public/index.html

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="robots" content="noindex"> <title>Check the Lock</title> <link rel="manifest" href="/manifest.json"> <!-- update the version number as needed --> <script defer src="/__/firebase/8.4.2/firebase-app.js"></script> <!-- include only the Firebase features as you need --> <script defer src="/__/firebase/8.4.2/firebase-firestore.js"></script> <script defer src="/__/firebase/8.4.2/firebase-messaging.js"></script> <!-- Initialize Firebase --> <script defer src="/__/firebase/init.js"></script> <style media="screen"> body { background: #ECEFF1; color: rgba(0, 0, 0, 0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; } body.open { background: #d84655; } body.close { background: #119227; } #message { max-width: 360px; margin: 200px auto 16px; padding: 32px 24px; } #message h1 { font-size: 128px; font-weight: 300; color: white; margin: 0 0 16px; text-align: center; } #load { color: rgba(0, 0, 0, 0.4); text-align: center; font-size: 13px; } </style> </head> <body> <div id="message"> <h1 id="status"></h1> </div> <p id="load">Firebase SDK Loading&hellip;</p> <script> document.addEventListener('DOMContentLoaded', async function () { const loadEl = document.querySelector('#load'); try { let app = firebase.app(); let features = [ 'firestore', 'messaging', ].filter(feature => typeof app[feature] === 'function'); loadEl.textContent = `Firebase SDK loaded with ${features.join(', ')}`; // 初回はFirestoreの最新データを使ってステータスを表示 const db = firebase.firestore(); const snapshot = await db.collection('logs').orderBy('timestamp', 'desc').limit(1).get(); if (!snapshot.empty) { const doc = snapshot.docs[0]; const data = doc.data(); console.log(data); showStatus(data.distance); }; // フォアグラウンドでpushメッセージを受け取ったときの処理 const messaging = firebase.messaging(); messaging.onMessage((payload) => { console.log('Message received. ', payload); if (payload.data) { // 計測した距離を受信したらステータスに反映 const distance = parseInt(payload.data.distance); showStatus(distance); } if (payload.notification) { // フォアグラウンドで通知を受け取ったら、通知を表示 alert(`${payload.notification.title}\n${payload.notification.body}`); } }); // 通知の購読を開始 initMessaging(messaging); } catch (e) { console.error(e); loadEl.textContent = 'Error loading the Firebase SDK, check the console.'; } }); function showStatus(distance) { const statusEl = document.getElementById('status'); const status = distance > 120 || distance === 20 ? '開' : '閉'; statusEl.innerText = status; if (status === '開') { document.body.classList.add('open'); document.body.classList.remove('close'); } else { document.body.classList.add('close'); document.body.classList.remove('open'); } } function initMessaging(messaging) { registSW(); // デバイス登録トークンを取得し、Firestoreの保存 // vapidKeyは、Firebaseプロジェクトの[プロジェクトの概要]からWebアプリを開き、[Cloud Messaging]タブの[ウェブプッシュ証明書] > [鍵ペア]の値に置き換えます。 messaging.getToken({ vapidKey: '鍵ペアの値をコピー&ペースト }).then((currentToken) => { console.log(currentToken); if (currentToken) { storeToken(currentToken); } else { // Show permission request. alert('No registration token available. Request permission to generate one.'); } }).catch((err) => { alert('An error occurred while retrieving token. ' + err); }); } async function storeToken(currentToken) { const db = firebase.firestore(); const docRef = await db.collection("tokens").add({ token: currentToken, timestamp: new Date() }); console.log("Document written with ID: ", docRef.id); } function registSW() { if ('serviceWorker' in navigator) { window.addEventListener('load', function () { navigator.serviceWorker.register('/firebase-messaging-sw.js') .then(function (registration) { console.log('firebase-messaging-sw.js registration successful with scope: ', registration.scope); }, function (err) { alert('firebase-messaging-sw.js registration failed: ' + err); }); }); } } </script> </body> </html>

サービスワーカー側のコードです。

基本的には何もしていませんが、バックグラウンドで通知を受け取るのには登録が必要なので用意する必要があります。

public/firebase-messaging-sw.js

importScripts('https://www.gstatic.com/firebasejs/8.3.2/firebase-app.js'); importScripts('https://www.gstatic.com/firebasejs/8.3.2/firebase-messaging.js'); // [プロジェクトの設定] > [全般]タブの[SDK の設定と構成]で「CDN」を選択して表示されるスクリプトに置き換える var firebaseConfig = { apiKey: "xxxxxxxxxxxxxxxxxxxx", authDomain: "your-project-id.firebaseapp.com", projectId: "your-project-id", storageBucket: "your-project-id.appspot.com", messagingSenderId: "0000000000000", appId: "1:666167191710:web:000000000000000000000" }; // Initialize Firebase firebase.initializeApp(firebaseConfig); const messaging = firebase.messaging(); messaging.onBackgroundMessage(function (payload) { console.log('[firebase-messaging-sw.js] Received background message ', payload); });

まとめと課題、検討など

コンテストは時間制限があったため、検証結果断念したこともまとめておきます。

サーバーレスイベントを利用していない理由

もともとサーバーレスイベント (online) を利用して、「距離計測 > 1分間スリープ > 距離計測 > 1分間スリープ > 距離計測 > IOトリガーでスリープ」というような動作を実現したかったのですが、焦電型赤外線センサーのようにパスル信号が連続しがちな入力をIOトリガーに設定すると、スリープ後すぐにobniz Boardが復帰して、2回目以降の起動ではサーバーレスイベントが呼び出されずobnizが起動しっぱなしになってしまうことがわかりました。

これは、スイッチを連打したりしても普通に発生します。

解決方法としては、トリガーとして設定した入力の前段で、信号を処理を入れる必要があるのですが、今回は時間的に断念しています。

VL53L0XのIO01を復帰トリガーに利用できないか?

VL53L0Xは、IO01は距離の変化を出力するようにプログラミングできるAPIが用意されているため、今回のシステムは、実際は焦電型赤外線センサーはなくてもobniz BoardとVL53L0Xのみで実現できるはずです。

そうするとさらに部品点数を減らして、小型化ができそうです。

しかし、VL53L0X用のobniz パーツライブラリはIO01の処理については未対応です。パーツを拡張すれば良さそうですが、データシートを読み込んでI2C通信を実装するのは時間的に間に合いませんでした。

これをやろうと思うと、obnizOSのプラグインを利用して、IOのプログラムは公式のAPIで実装して、obniz Cloudを利用するような構成の方が楽そうですね。

ステータスの変化はpushメッセージではなくFirestoreのリスナーを使いたい

PWAでは、鍵の開閉ステータスの更新にもpush通知を使用していますが、せっかくFirestoreを使っているので、Firestoreのリスナーでコレクションに計測結果のドキュメントが追加されたことを検知して表示を切り替えたいところです。しかし、今回は時刻指定のクエリーでポーリングするような実装しか思いつかず不採用に。
このあたりは引き続き改善を検討したいところです。

obnizの接続クローズ忘れに注意

obnizの制御は、Firebase functionsで記述していますが、当初await obniz.closeWait();が漏れているロジックがあり、しばらく実行すると接続数が足りない以下のエラーが発生してobnizに接続できなくなる問題に当たり、結構ハマりました。。

Error: obniz=null is connected from 0 clients (it's max). Disconnect others or extend limit.

クローズしないとobniz.jsが接続を試行し続けて、functionsのプロセスが残ってしまうような動きになってしまったのかもしれません。

特にサーバーレスな環境で実行する場合は接続のクローズ漏れには注意が必要そうです。
コード例のようにエラーが発生しそうなところは囲ってfinallyawait obniz.closeWait();を呼ぶとしとけば間違いなさそうですね。

おわりに

最後まで目を通していただきありがとうございました。
おそらく募集要項は満たしていると思うので、無事obniz Board 1Yは手元において置けるかなと思います。次に何作ろう。

それから、Noteの方でもいくつかobnizの記事を書いていますので、気になった方は以下のコピペテックの記事も読んでみてください!

https://note.com/copipetech

  • hrendoh さんが 2021/05/13 に 編集 をしました。 (メッセージ: 初版)
  • hrendoh さんが 2021/05/13 に 編集 をしました。
  • hrendoh さんが 2021/05/13 に 編集 をしました。
  • hrendoh さんが 2021/05/13 に 編集 をしました。 (メッセージ: アイキャッチを追加、まとめを追記)
  • hrendoh さんが 2021/05/14 に 編集 をしました。 (メッセージ: ピンアサイン表を追加)
  • hrendoh さんが 2021/05/16 に 編集 をしました。 (メッセージ: 仕様を校正)
ログインしてコメントを投稿する