team_shinkaiLab が 2021年05月13日23時51分18秒 に編集
初版
タイトルの変更
お出かけ先の天気と連動するIoT傘立て
タグの変更
obnizIoTコンテスト
obnizBoard1Y
ソレノイド
Googleカレンダー
Bluetooth
記事種類の変更
製作品
本文の変更
# 概要 obnizを使ったIoT傘立てを作ってみました。 スマホの接近をトリガーとして、カレンダーにあるお出かけ先の天気予報を取得し、雨の予報であれば傘を倒して知らせます。 「Bluetooth」x「Google カレンダー」x「OpenWeatherMap」x「ソレノイド」を組み合わせて実装しました。 # デモ動画 @[youtube](https://youtu.be/-Bv-Vx2mpnc) 傘立てに接近し、行先の予報が雨の場合のデモ動画です。 # 用意するもの - 傘 - Android端末 # 構成部品 - obniz Board 1Y - プッシュソレノイド(タカハ機工 CBS1240) - 傘立て - ストッパー部品 - モバイルバッテリー(5V) # 傘立て本体 ![キャプションを入力できます](https://camo.elchika.com/18b33ce4faba8b1cb3c9d17387f6531a06a79a96/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f36366164333262622d303162652d343132322d386635612d6435663538346336393232382f37396135656434382d313163302d343235372d393238622d316463346665626130643266/) 傘立ては、フリーに回転できる傘のストッパー部品と、ストッパー部品をロックするソレノイドで構成されます。 ソレノイドが動作することでストッパー部品のロックが解除されます。 ロックが解除されると傘の自重でストッパー部品が回転し、傘のストッパーも外れます。 **動作の流れ** Step 0 傘をストッパーに立てかけます。 Step 1 ソレノイドを動作させ、ストッパー部品の回転を自由にします。 ソレノイドは図中矢印1の方向に動作します。 Step 2 ストッパー部品が傘の自重で回転し、傘が倒れます。 ストッパーは図中矢印2の方向に回転します。 # 配線図 ![キャプションを入力できます](https://camo.elchika.com/3d9b7cd2f23a4b00c3451d4238743e79d26c3c68/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f36366164333262622d303162652d343132322d386635612d6435663538346336393232382f63396531303637392d633537622d343730332d613331612d646231393564616534666361/) # システムフロー :::plantuml @startuml start :BLEスキャン開始; repeat; if (BLEスレーブ検出?) then (yes) if (RSSI値が一定以上?) then (yes) :お出かけ検出!!; :Googleカレンダーから本日のお出かけ先取得; if (お出かけ先が雨?) then (yes) :ソレノイドオン!!; :傘が倒れる; :ソレノイドオフ!!; endif endif endif repeat while() @enduml ::: # 実装方法 ## お出かけ検出 - obnizをBLE(Bluetooth Low Energy)マスター、スマホをBLEスレーブとして動かす - 適当なビーコン(iBeacon)アプリをスマホにインストールする必要がある - https://play.google.com/store/apps/details?id=net.alea.beaconsimulator - obnizは常にスキャンを行って、得られるRSSI値を簡易的な距離として扱い閾値によって判別する - ビーコンアプリで指定したUUIDを用いて対象のスマホのみを検出する - https://obniz.com/ja/doc/reference/common/ble/central-scan ## お出かけ先取得 - Googleカレンダーから本日の予定を取得する - APIキー、OAuthクライアントIDの取得 - Google Cloud Platformでプロジェクトを作成してGoogle Calendar APIを有効にする - [API とサービス]→[OAuth 同意画面] でOAuth 同意画面を構成する - [API とサービス]→[認証情報] の[認証情報を作成]でAPIキーとOAuthクライアントIDを作成する - obnizのWEBアプリ上からGoogleアカウントを認証する - [Google API Client Library for JavaScript](https://github.com/google/google-api-javascript-client)で上記のAPIキーとOAuthクライアントを用いて認証する (自動ログアウトされるまでは一度だけログインすればよい) - お出かけ検出時にカレンダー取得 - 認証済みの[Google API Client Library for JavaScript](https://github.com/google/google-api-javascript-client)インスタンスでカレンダーAPI(calendar.events)にアクセスし、取得する - 予定([Events](https://developers.google.com/calendar/v3/reference/events#resource))内のlocationからお出かけ先を文字列として取得する - 場所文字列を緯度・経度に変換する - [LocationIQ](https://locationiq.com/)のGeocoding APIを使用する - [LocationIQ](https://locationiq.com/)でアカウントを作成し、APIキーを取得する - 場所文字列とAPIキーを指定してfetchすることで緯度・経度が取得できる - (デフォルトの現在地として東京の緯度・経度は直指定で常に検索される) ## 天気検出 - [OpenWeatherMap](https://openweathermap.org/)を使用する - [OpenWeatherMap](https://openweathermap.org/)でアカウントを作成し、APIキーを取得する - One Call APIに緯度・経度とAPIキーを指定してfetchすることで日別の天気が取得できる - 本日が悪天候の場合はソレノイドをオンにする # ソースコード ```html <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"/> <script src="https://code.jquery.com/jquery-3.2.1.min.js"></script> <script src="https://unpkg.com/obniz@3.14.0/obniz.js" crossorigin="anonymous"></script> </head> <body> <div id="obniz-debug"></div> <h3>Connect From Your Browser</h3> <button class="btn btn-primary" id="on">Solenoid ON</button> <button class="btn btn-primary" id="off">Solenoid OFF</button> <!--Add buttons to initiate auth sequence and sign out--> <button id="authorize_button" style="display: none;">Authorize</button> <button id="signout_button" style="display: none;">Sign Out</button> <div>Demo Pin Assign</div> <ul> <li>io0: Solenoid anode</li> <li>io1: Solenoid cathode</li> </ul> <script> // BLE const BLE_UUID = "YOUR_iBeacon_UUID"; const RSSI_THRES = -80; // OpenWeatherMap const WEATHER_APIKEY = "YOUR_WEATHER_APIKEY _HERE"; const DEFAULT_LOCATION = {lat: 35.681236, lon:139.767125}; // 東京 // LocationIQ const LOCATIONIQ_APIKEY = "LOCATIONIQ_APIKEY_HERE"; // Google const GOOGLE_APIKEY = "YOUR_GOOGLE_APIKEY_HERE"; const OAUTH_CLIENT_ID = "YOUR_OAUTH_CLIENT_ID_HERE"; const DISCOVERY_DOCS = ["https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"]; const GAPI_SCOPES = "https://www.googleapis.com/auth/calendar.events.readonly"; const obniz = new Obniz("YOUR_OBNIZ_ID_HERE"); let fired = false; let lastTimeoutID = null; let isGoogleSignedIn = false; const authorizeButton = document.getElementById('authorize_button'); const signoutButton = document.getElementById('signout_button'); const Weather = { None: 0, Good: 1, Bad: 2, }; obniz.onconnect = async function() { const sole = obniz.wired("Solenoid", { signal: 1, gnd: 0 }); // debug $("#on").click(function() { sole.on(); obniz.display.clear(); obniz.display.print("ON"); }); $("#off").click(function() { sole.off(); obniz.display.clear(); obniz.display.print("OFF"); }); // Bluetooth await obniz.ble.initWait(); obniz.ble.scan.onfind = async function(peripheral){ console.log(`rssi = ${peripheral.rssi}`); if(peripheral.rssi > RSSI_THRES) { if(!fired) { fired = true; console.log("fire!"); let isBadWeather = (await getWeather(DEFAULT_LOCATION.lat, DEFAULT_LOCATION.lon)) == Weather.Bad; // Googleカレンダーから今日の予定を取得 if(!isBadWeather && isGoogleSignedIn){ const events = await getCalendarEvents(); const filteredEvents = events.filter(e=> isSameDate(e.date, getToday()) && e.location !== "" && !!e.location); for(let event of filteredEvents) { const location = await geocoding(event.location); if(!location){ continue; } const weather = await getWeather(location.lat, location.lon); if(weather == Weather.Bad) { isBadWeather = true; break; } } } if(isBadWeather) { console.log("Bad Weather"); sole.on(); await sleep(2000); sole.off(); } else { console.log("Good Weather"); } // 一分後に復活 clearTimeout(lastTimeoutID); lastTimeoutID = setTimeout(()=>{fired = false}, 60 * 1000); } } else { fired = false; clearTimeout(lastTimeoutID); } }; obniz.ble.scan.onfinish = async function(peripherals, error){ console.log("scan timeout!"); }; await obniz.ble.scan.startWait( { binary: [uuidToByteArray(BLE_UUID)] }, { duplicate: true, duration: null, filterOnDevice: true, }); console.log("start!") }; // 天気取得 // https://blog.obniz.com/make/led-integration-weather-api // lat 緯度 // lon 経度 async function getWeather (lat, lon) { const BadWeathers = [ "Rain", "Snow", "Thunderstorm", "Drizzle", "Fog", "Squall", "Mist", "Smoke", "Haze", "Tornado", "Ash", ]; const GoodWeathers = [ "Clouds", "Clear", "Dust", "Sand", ]; const url = `https://api.openweathermap.org/data/2.5/onecall?lat=${lat}&lon=${lon}&exclude=minutely,hourly&appid=${WEATHER_APIKEY}`; const data = await (await fetch(url)).json(); console.log(data); const currentWeather = data.current && data.current.weather.length > 0 ? data.current.weather[0].main : null; const todayWeather = data.daily && data.daily[0] && data.daily[0].weather.length > 0 ? data.daily[0].weather[0].main : null; console.log(`current = ${currentWeather}, today = ${todayWeather}`); if (BadWeathers.includes(currentWeather) || BadWeathers.includes(todayWeather)) { return Weather.Bad; } else if (GoodWeathers.includes(currentWeather) || GoodWeathers.includes(todayWeather)) { return Weather.Good; } console.log("no data"); return Weather.None; } // 緯度経度取得 async function geocoding(locationStr) { try { const trimmedLocation = locationStr.replace(/,.*/, ""); // ","以降を消す const url = `https://us1.locationiq.com/v1/search.php?key=${LOCATIONIQ_APIKEY}&q=${trimmedLocation}&format=json`; const data = await (await fetch(url)).json(); const location = data[0]; if(location){ return {lat: location.lat, lon: location.lon}; } } catch(e){ console.log(e); } return null; } // カレンダー取得 // https://developers.google.com/calendar/quickstart/js async function getCalendarEvents() { const response = await gapi.client.calendar.events.list({ 'calendarId': 'primary', 'timeMin': getToday().toISOString(), 'showDeleted': false, 'singleEvents': true, 'maxResults': 10, 'orderBy': 'startTime' }); const ret = []; for(let event of response.result.items) { const date = new Date(Date.parse(event.start.date)); const location = event.location; ret.push({date, location}); } return ret; } // Google認証関連 // https://developers.google.com/calendar/quickstart/js function handleClientLoad() { gapi.load('client:auth2', initClient); } function initClient() { gapi.client.init({ apiKey: GOOGLE_APIKEY, clientId: OAUTH_CLIENT_ID, discoveryDocs: DISCOVERY_DOCS, scope: GAPI_SCOPES }).then(function () { gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus); updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get()); authorizeButton.onclick = () => gapi.auth2.getAuthInstance().signIn(); signoutButton.onclick = ()=> gapi.auth2.getAuthInstance().signOut(); }, function(error) { console.log(JSON.stringify(error, null, 2)); }); } function updateSigninStatus(isSignedIn) { isGoogleSignedIn = isSignedIn; if (isSignedIn) { authorizeButton.style.display = 'none'; signoutButton.style.display = 'block'; } else { authorizeButton.style.display = 'block'; signoutButton.style.display = 'none'; } } // util async function sleep(msec) { return new Promise(resolve => setTimeout(resolve, msec)); } function uuidToByteArray(uuid) { return [...uuid.matchAll(/\w\w/g)].map(byte => Number.parseInt(byte, 16)) } function isSameDate(a, b){ return a.getFullYear() == b.getFullYear() && a.getMonth() == b.getMonth() && a.getDate() == b.getDate(); } function getToday() { const today = new Date(); return new Date(today.getFullYear(), today.getMonth(), today.getDate()); } </script> <script async defer src="https://apis.google.com/js/api.js" onload="this.onload=function(){};handleClientLoad()" onreadystatechange="if (this.readyState === 'complete') this.onload()"> </script> </body> </html> ```