編集履歴一覧に戻る
men100のアイコン画像

men100 が 2022年09月25日23時36分59秒 に編集

初版

タイトルの変更

+

Spresense を使用した「Leave No Student Behind」システムの開発

タグの変更

+

SPRESENSE

+

Arduino

+

ELTRES

+

オーディオ

+

LCD

+

焦電センサ

+

加速度センサー

+

GPS

+

Python

メイン画像の変更

メイン画像が設定されました

記事種類の変更

+

製作品

ライセンスの変更

+

(CC BY 4+) Creative Commons Attribution CC BY version 4.0 or later

本文の変更

+

# システムの概要 今回は Spresense を使用した置き去り防止のシステムを開発しました。これにより、以下を実現します。 - 運転の開始と終了を検知する - 運転終了時は警告音を発し、ボタンを押させることにより運転手に車内の確認を促す - ボタン押下後はセンサーによる監視を開始する - センサーに反応があった場合は、その旨を本システム登録者のスマートフォンに通知する # 開発の背景 今月9月の初めになりますが、幼稚園児が送迎バスに取り残され死亡してしまうという衝撃な事件がありました。私にも小さい娘がいるため、他人事とは思えませんでした。事件の詳細についてはここでは述べませんが、原因の最大の理由はバスの停車後、車内を確認しなかったという、ヒューマンエラーにあるのは間違いないと思います。 本事件に関連した情報として、アメリカでの話を下記で見かけました。 米ではセンサーが置き去り検知…今後日本でも導入へ (テレ朝ニュース) https://news.tv-asahi.co.jp/news_society/articles/000268016.html こちらのニュースを抜粋すると以下のような内容になります。 1. アメリカでは「Leave No Student Behind」という装置がスクールバスに導入されている 2. バスのエンジンを切ると、この装置から警告音が鳴り響く。警告音を消すにはバス後方のボタンを押さなければならない 3. 後方に移動するまでに園児を発見できるので、置き去りを防ぐことができる 4. それでも人の目が見逃したケースに対する対策も存在する。それは天井にセンサーを取り付け、置き去りになった園児を検知するというもの 5. 検知すると登録したスマートフォンにその旨のメッセージが送信される こちらのニュースを読んで思いました。「これ、Spresense で実現できるのではないか?」と。エンジニアの端くれとして、今回 Spresense 版「Leave No Studen Behind」装置の製作とシステム開発に挑戦してみることにしました。 # 本システムの構成要素 本システムは大きく分けて2つの構成要素から成ります。 ## 1. Spresense を使用した装置 (以下、LNSB Client と記述する) 車内に設置され、外界の状態を把握することを担当する装置です。状況に応じて効果音を再生したり、運転手に操作を促したり、定期的に状態を外部に送信します。 ## 2. 状態を把握し、システム登録者に伝えるためのサーバープログラム (以下、LNSB Server と記述する) 1 の装置によって送信された状態を取得し、それに応じたメッセージをシステム登録者のスマートフォンに通知するプログラムです。 # LNSB Client の部品一覧 ![LNSB Client の上部からの写真](https://camo.elchika.com/fbd04b4be8756817a3b71d78332df95e58bc4dc4/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f61393333383331622d353038352d346330362d613937622d363664653961343938663063/) | 部品名 | 説明・補足など | |:---|:---| | Spresense メインボード | メインボード | | Spresense 拡張ボード | 拡張ボード | | ELTRES アドオンボード | クレスコ・デジタルテクノロジーズ製。ELTRES 通信を行うのに使用します | | GPS アンテナ | GNSS の受信感度を上げるのに使用します | | LPWA アンテナ | ELTRES ペイロード送信に必要です | | Spresense 用 BMP280 BMI160 搭載 3軸加速度・3軸ジャイロ・気圧・温度センサアドオンボード (以下、加速度センサーと記述する) | スイッチサイエンス製。加速度の計測に使用します | | プッシュボタン | 今回使用したのは家に転がっていた Panasonic 製。ボタンの役割をするなら他のスイッチなどでも OK | | 3.5mm ミニプラグ モノラルスピーカー(以下、モノラルスピーカーと記述する) | 今回使用したのはダイソーで購入したもの。音が出るなら他のスピーカーでも OK | | 焦電型赤外線センサー EKMC16011112 (以下、赤外線センサーと記述する) | 今回使用したのは秋月電子で購入した Panasonic 製のもの。他のものでも動くとは思います | | micro SD カード 4GB (以下、micro SD カードと記述する) | 今回使用したのは東芝製。効果音の MP3 ファイルを格納するのに使用します。他のメーカーのものでも問題ないと思います。容量も4GBも不要で、128MBくらいでも十分です | | ILI9341 コントローラ内蔵 LCD (以下、LCD と記述する) | 今回使用したのは aitendo で購入したもの。デバッグ用なので必須ではないです | | micro USB ケーブル | Spresense とモバイルバッテリー接続に使用 | | モバイルバッテリー QE-PL201 (以下、モバイルバッテリーと記述する) | 今回使用したのは Panasonic 製。低電力モードに対応したものを選びましょう。そうでないとバッテリーが出力を停止してしまいます | | 線材 | LCD、プッシュスイッチ、赤外線センサーを Spresense 拡張ボードと接続するのに使用しました | | バスケット | 今回使用したのは100円均一ショップで購入したもの。こちらに上記部品を格納します | # LNSB Client 内部品の接続について ![ブロック図](https://camo.elchika.com/c38b8a167a4c2a34660ce94d7291cf2e7a7bb51e/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f33343830333331662d353634342d343132642d623065342d316537316130343337386432/) 各部品の接続について説明します。なお、Spresense 側ピン名については "CXD5602 pin name (Arduino compatible Pin name)" の書式で書いています。 ## Spresense メインボードと Spresense 拡張ボードの接続 それぞれにの基板に付いている拡張コネクタを向かい合わせるようにし、接続します。 ## Spresense メインボードと ELTRES アドオンボードの接続 ELTRES アドオンボードとは UART を使って通信します。接続としては ELTRES アドオンボードの CN1_1 ピンを左上とし、Spresense メインボードの左上の1番ピンに合わせるようにします。ピンの対応としては下記のようになります。 | ELTRES アドオンボード | Spresense メインボード | |:---|:---| | CN1_1 | UART2_TX (D01) | | CN1_2 | UART2_RX (D00) | | CN1_3 | UART2_RTS (D28) | | CN1_4 | UART2_CTS (D27) | | CN1_5 | I2S0_BCK (D26) | | CN1_6 | I2C0_LRCK (D25) | | CN1_7 | SPI5_CS_X (D24) | | CN1_8 | SPI5_SCK (D23)* | | CN2_1 | XRST_PIN_1.8V* | | CN2_2 | 1.8V | | CN2_3 | EXT_VDD | | CN2_4 | EMMC DATA3 (D21)* | | CN2_5 | EMMC DATA2 (D20)* | | CN2_6 | I2S0 DATA_IN (D19)* | | CN2_7 | IS20 DATA_OUT (D18)* | | CN2_8 | SPI5_MISO (D17)* | \* ELTRES アドオンボード取扱説明書を参考にすると、CN1_8、CN2_1、CN2_4、CN2_5、CN2_6、CN2_7、CN2_8 については使用しておりません。 ## ELTRES アドオンボードと LPWA アンテナ、GPS アンテナの接続 ELTRES アドオンボードに付いているアンテナケーブル接続用レセプタクル (U.FL1) にそれぞれのアンテナを接続します。 ## Spresense メインボードとモバイルバッテリーの接続 micro USB ケーブルで Spresense メインボードとモバイルバッテリーを接続します。 ## Spresene メインボードと加速度センサーの接続 加速度センサーとは I2C を使って通信します。接続としては加速度センサーの 3.3V ピンを左上とし、Spresense メインボードの 3.3V ピン(左の下から4番目)に合わせるようにします。ピンの対応としては下記のようになります。 | 加速度センサー | Spresense メインボード | |:---|:---| | 3.3V | 3.3V* | | 1.8V | 1.8V | | SEN_IRQ | SEN_IRQ_IN (D22)* | | SEN_AIN4 | SEN_AIN4 (A2)* | | GND | GND | | I2C0_SCL | I2C0_SCL (D15) | | I2C0_SDA | I2C0_SDA (D14) | | SEN_AIN5 | SEN_AIN5 (A3)* | \* 下記の加速度センサーの回路図にありますように、3.3V、SEN_IRQ、SEN_AIN4、SEN_AIN5 については使用しておりません。 https://docid81hrs3j1.cloudfront.net/medialibrary/2019/03/SPRESENSE_BMI160_BMP280.pdf ## Spresense 拡張ボードとモノラルスピーカーの接続 Spresense 拡張ボード搭載の 3.5mm ステレオジャックにモノラルスピーカーのプラグを接続します。 ## Spresense 拡張ボードと micro SD カードの接続 Spresense 拡張ボード搭載の micro SD カードスロットに micro SD カードを挿入します。 ## Spreesense 拡張ボードと LCD の接続 LCD とは SPI を使って通信します。接続としては下記のように対応させます。 | LCD | Spresense 拡張ボード | |:---|:---| | VCC | 3.3V | | GND | GND | | CS | SPI4_CS_X (D10) | | RESET | SPI2_MISO (D8) | | DC/RS | PWM2 (D9) | | SDI/MOSI | SPI4_MOSI (D11) | | SCK | SPI4_SCK (D13) | | LED | 3.3V | | SDO/MISO | SPI4_MISO (D12) | ## Spresense 拡張ボードとプッシュボタンの接続 プッシュボタンはデジタル入力として処理して値を取得します。接続としてはプッシュボタンの一方を Spresene 拡張ボードの SPI3_CS1_X ピン (D07 ピン) に接続し、もう一方は GND へ繋ぎます。 ## Spresense 拡張ボードと赤外線センサーの接続 赤外線センサーはアナログ入力として処理して値を取得します。接続としては下記のようになります。 | 赤外線センサー | Spresense 拡張ボード | |:---|:---| | VDD | 3.3V | | OUTPUT | SEN_AIN2 (A0) | | GND | GND | # LNSB Client の動作想定環境 今回製作した LNSB Client は下記の条件下での動作を想定しています。 - スクールバス・・・は用意できなかったので、自家用車であるホンダのフィットを LNSB Client の動作対象車両とする - そのため、走行開始・終了検知はエンジン車の特性を利用している (詳細については "LNSB Client の状態遷移図" の項にて行います) ![ホンダのフィット](https://camo.elchika.com/bb1ed7452006df34014afe9bbf10df6bfecd378e/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f63383430323566612d333631652d343563662d383933632d373433643134383737646465/) エンジン車ならフィットに限らず動作するものと考えますが、当方では動作は未確認です。 ## LNSB Client の設置場所 設置場所についても記述しておきます。今回は開発時の実験の利便性などからダッシュボードに LNSB Client を設置しました。運転時に視界を遮らないように左端に置くように配慮しました。 ![ダッシュボード左端に設置](https://camo.elchika.com/78c21a8f92406ac48400bf37ac2b42560edd5f63/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f39333263383366612d333563352d346235642d613234382d383264333663613533613438/) ![前から見たところ](https://camo.elchika.com/2f0267ce85d23bf127e31b660c15839b1511a0eb/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f33613863326238342d373465392d343836622d626635652d363963336165346239376232/) # LNSB Client の状態遷移図 LNSB Client の状態遷移図を示します。 ![LNSB Client の状態遷移図](https://camo.elchika.com/059a3843c6e5605179cb37ee8d954d628d14ca70/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f66323564643333392d646566662d343163612d393063632d616531306434326635626366/) ## INITIALIZE 状態 プログラムが起動すると INITIALIZE 状態から始まります。ここでは接続された各種デバイスやライブラリの初期化を行います。初期化に成功した場合は POSTIONING 状態に遷移し、失敗した場合はプログラムを終了します。 POSITIONING 状態への遷移時は遷移したことを示す効果音を再生します。 ## POSITIONING 状態 POSITIONING 状態は GNSS 測位を試みていることを示し、GNSS 測位ができるまで待機します。GNSS 測位が行えたら WAIT_FOR_DRIVING 状態に遷移します。 WAIT_FOR_DRIVING 状態への遷移時は遷移したことを示す効果音を再生します。 ## WAIT_FOR_DRIVING 状態 WAIT_FOR_DRIVING 状態は走行前であることを示し、車両の走行開始を監視します。今回の動作想定環境としてはエンジン車なので、エンジン始動の検出を試みます。検出した場合は ON_DRIVING 状態へ遷移します。 ON_DRIVING 状態への遷移時は下記の処理を行います。 - 遷移時の測位した場所を走行開始地点として記録する - 遷移したことを示す効果音を再生する ### エンジン始動の検出について エンジン始動の検出は加速度センサーを使って行います。エンジン車はエンジン内ピストンの振動によって細かく振動することは普段の乗車から体感的に分かっていたので、これを検出に使えないかなと考えました。 そこで実験を行い、測定を行いました。エンジン始動時の加速度センサー z 軸の値を以下に示します。x 軸が取得ポイントナンバーで、0から始まり1ずつ増えて行きます。y 軸が加速度センサー z 軸の値です。各ポイント間の間隔は 5ms となっております。 ![エンジン始動時の加速度センサー z 軸の値](https://camo.elchika.com/5a16e51b7e2234fb6b6eddeffde034440b3eec1b/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f32636137313866662d636531662d346239632d623334652d376634613339303432346263/) 予想通り、エンジン始動後に z 軸の値が振動していることがデータからも分かりました。この結果を踏まえ、振動を検出するのには下記のような方法を今回は採用しました。 - 加速度センサー z 軸の値を プラス成分とマイナス成分に分け、一定数の値を保存する - 保存した値でそれぞれ平均を計算し、しきい値を超えていればエンジン始動とみなす プラス成分とマイナス成分に分けるのは、下記の狙いがあります。 - 振動のため、そのまま合算することで相殺されることを防ぐ - 一方方向の瞬間的なスパイクを除去する 上記の実験結果に対し、今回の採用方法を取ったものが以下になります(平均対象のデータは200レコード) ![加速度センサー z 軸のプラス成分の平均値グラフ](https://camo.elchika.com/c0e4d6e45fddac97cc93b34a209c57348d75e570/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f34393030633064392d323166632d343130322d613338362d316433643837376435623166/) ![加速度センサー z 軸のマイナス成分の平均値グラフ](https://camo.elchika.com/3ad38ba01b37dfed65fbba25cff3fe554d154edb/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f39636435623762322d613638362d343164662d626166352d386630653939376531376564/) それぞれ、時刻が進むにつれ、加速度センサーの z 軸の値が大きく (マイナス成分は小さく) なっていることが見れました。今回はしきい値として 0.02 を設定し、プラス成分はそれを超え、かつマイナス成分は -0.02 を下回ったときにエンジン始動判定とします。 ## ON_DRIVING 状態 ON_DRIVING 状態は運転中であることを示し、車両の走行終了を監視します。今回の動作想定環境としてはエンジン車なので、エンジン停止の検出を試みます。また、WAIT_FOR_DRIVING 状態からの遷移時に記録した走行開始地点と現在の位置を比較し、距離が設定したしきい値より大きい場合はエンジン停止を検出しても次の状態には遷移しません。この処理により、途中で違う場所で立ち寄り、エンジン停止をしても走行終了とみなさないようにします。 まとめると、以下の場合に次の状態である STOPPED 状態に遷移します。 - エンジン停止を検出した - 現在の位置と走行開始地点との距離がしきい値以下に収まっている STOPPED 状態への遷移時は遷移したことを示す効果音を再生します。 ### エンジン停止の検出について エンジン停止の検出については、エンジン始動の検出における逆のアプローチで上手くいきました。つまり、振動状態から収束したタイミングになったら停止と判定するということです。 下記はエンジン動作中からエンジン停止したときの加速度センサーの z 軸の値のグラフで、x 軸が取得ポイントナンバーで、0から始まり1ずつ増えて行きます。y 軸が加速度 z 軸の値です。各ポイント間の間隔は 5ms となっております。 ![エンジン停止時の加速度センサー z 軸の値](https://camo.elchika.com/6b567af990fc7fff56ebc454ccbe2d0d07cc2ed8/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f32396664656538632d353266382d346532382d626231612d656436646566313339386535/) 停止したあと収束しているのが分かります。この結果に対し、エンジン始動と同様のアプローチを取ったグラフが下記になります。 ![加速度センサー z 軸のプラス成分の平均値グラフ](https://camo.elchika.com/b4d50c8cacdbe96cbba1a71a5243371ac7853072/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f65343433343134302d373564362d343663652d386134652d323339646562616533623836/) ![加速度センサー z 軸のマイナス成分の平均値グラフ](https://camo.elchika.com/aaebeab729086f4e017f40c96c39c00a75dff7d5/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f63626431386266662d336231392d343636612d626462662d383735396432383535313664/) それぞれ、時刻が進むにつれ、加速度センサーの z 軸の値が小さく (マイナス成分は大きく) なっていることが見れました。今回はしきい値として 0.004 を設定し、プラス成分はそれを下回り、かつマイナス成分は -0.004 を超えたときにエンジン停止判定とします。 ## STOPPED 状態 STOPPED 状態は車両が走行終了したことを示します。ここでは特に検出を試みることなどはせず、自動的に WAIT_FOR_BUTTON 状態へと遷移します。 WAIT_FOR_BUTTON 状態への遷移時は運転手にボタン押下を促す警告音を再生します。 ## WAIT_FOR_BUTTON 状態 WAIT_FOR_BUTTON 状態は運転手がボタンを押すことを待機していることを示します。プッシュボタンが押されると WAIT_FOR_EXIT 状態へと遷移します。警告音についてはループ設定の再生となっており、警告音が終了してもまた最初から再生するようになっており、ボタンが押されるまで音が止まることはありません。 WAIT_FOR_EXIT 状態への遷移時は遷移したことを示す効果音を再生します。 ## WAIT_FOR_EXIT 状態 WAIT_FOR_EXIT 状態は運転手の退出を待機していることを示します。30秒経過すると SURVEILLANCE 状態へ遷移します。 SURVEILLANCE 状態への遷移時は遷移したことを示す効果音を再生します。 ## SURVEILLANCE 状態 SURVEILLANCE 状態は人の検出を試みている状態を示します。赤外線センサーにより動きを検出したとき、人を検出したとみなし、CHILD_FOUND 状態へと遷移します。 CHILD_FOUND 状態への遷移時は遷移したことを示す効果音を再生します。 ## CHILD_FOUND 状態 CHILD_FOUND 状態は人の検出をしている状態を示します。LNSB Client としては最終的に到達する状態となり、これ以上遷移はしません。 ## 各状態共通での処理 各状態共通で行っている処理として、現在の状態を ELTRES の送信ペイロードのセットがあります。これにより定期的な ELTRES 通信の際、現在の状態を LNSB Server 側に通知することが可能です。 # その他、LNSB Client の動作に必要なこと 使用する micro SD カードに下記の効果音を保存しておいてください。 - 運転手にボタン押下を要求する警告音 (SOUND/Buzzer.mp3 として保存) - 状態遷移を示す効果音 (SOUND/Transition.mp3 として保存) 私が使用した mp3 についてはクレジットにて記載しておりますので、参考にしてください。なお、どちらも切り貼りなどの加工をしております。 # LNSB Server 側処理の概要 LNSB Server 側の処理について概要を示します。 1. ELTRES 通信データの解析サービスである CLIP Viewer Lite を監視 2. 最新のペイロードを取得し、そこから LNSB Client の現在の状態を取得 3. 状態に応じたメッセージを LINE 株式会社が提供する LINE Notify により、指定した LINE のグループに送信する これにより、置き去りのような重大なインシデントも即座にスマートフォンを介して伝えることを可能にします。 # デモ動画 [Spresense を使用した「Leave No Student Behind」システムの開発 デモ動画](https://youtu.be/AYyj72zxxts) デモの流れとしては下記のようになります。基本的には状態遷移図に沿ったものになりますが、途中走行開始地点でないところで停車し、正しく状態遷移しないことを確認しています。また人の検出することを確認するため、退出する段階でも退出せず待機しています。 1. 装置 (LNSB Client) の電源を入れ、初期化が完了し、GNSS 測位状態になるのを待つ 2. 状態遷移した旨の効果音が再生されることを確認 3. GNSS 測位が行えるまで待つ 4. 状態遷移した旨の効果音が再生されることを確認 5. 運転手は車のエンジンを始動 6. 状態遷移した旨の効果音が再生されることを確認 7. 車を発進させる 8. 途中、走行開始地点でないところで停車し、エンジンを停める 9. 状態遷移した旨の効果音が再生 "されない" ことを確認 10. 再び運転手は車のエンジンを始動、発進させる 11. 走行開始地点に停車し、エンジンを停める 12. 状態遷移した旨の効果音が再生されることを確認 13. プッシュボタン押下を促す警告音が再生されることを確認 14. 運転手はプッシュボタンを押す 15. 状態遷移した旨の効果音が再生されることを確認 16. 運転手は退出せず、30秒経過するのを待つ 17. 状態遷移した旨の効果音が再生されることを確認 18. 運転手は装置 (LNSB Client) 前で手を振る 19. 状態遷移した旨の効果音が再生されることを確認 下記はデモを行った際に私のスマートフォンに来た本システム用 LINE のグループ画面です。 ![LINE のグループ画面](https://camo.elchika.com/f24ab873538deb9cc5f196e279974119d4ff87eb/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f64383864633261312d636261392d346637392d626438642d356165656335313435376639/) 置き去り検知の通知がちゃんと来ていることが分かります。 # システム構成 次の項でソースコードを示す前に、システム構成について説明します。 ![システム構成図](https://camo.elchika.com/d3ce7183405da1c8af32ac13356dde89b48d67e5/687474703a2f2f73746f726167652e676f6f676c65617069732e636f6d2f656c6368696b612f76312f757365722f63623337316331332d356139632d343237622d613038382d3461646139373930626338612f38613939303266362d623138652d343138332d393830342d616139653939356664643266/) 赤で塗られたものがそれぞれ本システムのソフトウェア処理部となります。 ## Client Side LNSB Client 側のソフトウェア処理部です。 ### LNSB Client メインループを担当するソフトウェア処理部です。他の処理部から情報を取得し、次の状態への遷移を判断します。 ### Eltres ELTRES 通信を担当するソフトウェア処理部です。現在地の取得や現在の状態を送信などを行います。 ### Accel 加速度センサーから値の取得を担当するソフトウェア処理部です。 ### Button プッシュボタンから値の取得を担当するソフトウェア処理部です。 ### IR 赤外線センサーから値の取得を担当するソフトウェア処理部です。 ### DriveDetection 運転の始動と終了の検出を担当するソフトウェア処理部です。Eltres と Accel からデータを取得し、運転始動・終了の判定を行います。 ### ChildDetection 人の検出を担当するソフトウェア処理部です。IR からデータを取得し、人検出の判定を行います。 ### Sound 音の再生を担当するソフトウェア処理部です。 ## Server Side LNSB Server 側のソフトウェア処理部です。 ### LNSB Server LNSB Client の現在の状態取得とメッセージ送信を担当します。CLIP Viewer Lite から現在の状態を取得し、システム登録者のスマートフォンにメッセージを送信します。 # ソースコード 前項で説明したソフトウェア処理部ごとにソースコードを示します。 ## LNSB Client ```c++:State.h #ifndef __LNSB_STATE_H__ #define __LNSB_STATE_H__ #define STATE_INITIALIZE 0x0 // 初期化 #define STATE_POSITIONING 0x1 // 測位中 #define STATE_WAIT_FOR_DRIVING 0x2 // 走行待機 #define STATE_ON_DRIVING 0x3 // 走行中 #define STATE_STOPPED 0x4 // 停車 #define STATE_WAIT_FOR_BUTTON 0x5 // ボタン押下待機 #define STATE_WAIT_FOR_EXIT 0x6 // 退出待ち #define STATE_SURVEILLANCE 0x7 // 監視 #define STATE_CHILD_FOUND 0x8 // 子供を発見した #endif // __LNSB_STATE_H__ ``` ```c++:LNSB_Client.ino #include <stdio.h> // for sprintf #include <SDHCI.h> #include "State.h" #include "Eltres.h" #include "DriveDetection.h" #include "ChildDetection.h" #include "Lcd.h" #include "Sound.h" #include "Button.h" #include "Debug.h" #define BAUDRATE (115200) int state = STATE_INITIALIZE; SDClass sdCard; Lcd* pLcd = Lcd::getInstance(); const int stopButtonPin = 7; Button stopButton(stopButtonPin); DriveDetection driveDetection; ChildDetection childDetection; void setup() { Serial.begin(BAUDRATE); while (!Serial) { ; // wait for serial port to connect. Needed for native USB port only } while (!sdCard.begin()) { Serial.println("Insert SD card."); } pLcd->initialize(); driveDetection.initialize(); childDetection.initialize(); EltresInitialize(pLcd); stopButton.initialize(); SoundInitialize(&sdCard); state = STATE_POSITIONING; SoundPlay(SOUND_TRANSITION, false, false); } void loop() { unsigned long currentMsec = millis(); EltresSetStateForPayload(state); EltresUpdate(); driveDetection.update(currentMsec); stopButton.update(currentMsec); SoundUpdate(currentMsec); pLcd->update(); switch (state) { case STATE_POSITIONING: { if (EltresIsGgaReceiving()) { SoundPlay(SOUND_TRANSITION, false, false); state = STATE_WAIT_FOR_DRIVING; } } break; case STATE_WAIT_FOR_DRIVING: { // ShowAzAvgDataOnLcd(&driveDetection, pLcd, currentMsec); if (driveDetection.isStartDrive()) { // 現在地を出発地としてセット driveDetection.setInitialPotition(); SoundPlay(SOUND_TRANSITION, false, false); state = STATE_ON_DRIVING; } } break; case STATE_ON_DRIVING: { { // 何故かこの State から DriveDetection において ggaInfo にアクセスすると Exception するので、こちらで Get, Set する eltres_board_gga_info *ggaInfo = EltresGetLatestGgaInfo(); driveDetection.setCurrentPosition((const char*)ggaInfo->m_lat, (const char*)ggaInfo->m_lon); } if (driveDetection.isEndDrive() && driveDetection.isReturnedInitialPosition()) { SoundPlay(SOUND_TRANSITION, false, false); state = STATE_STOPPED; } // Debug ShowDistanceOnLcd(&driveDetection, pLcd, currentMsec); } break; case STATE_STOPPED: { if (!SoundIsPlaying()) { SoundPlay(SOUND_BUZZER, true, true); state = STATE_WAIT_FOR_BUTTON; } } break; case STATE_WAIT_FOR_BUTTON: { if (stopButton.isPushed()) { //Serial.println("pushed!"); stopButton.clear(); SoundStop(); SoundPlay(SOUND_TRANSITION, false, false); state = STATE_WAIT_FOR_EXIT; } } break; case STATE_WAIT_FOR_EXIT: { const int WAIT_LCD = 1000; const int WAIT_NEXT = 30000; static unsigned long previousMsecLcd = currentMsec; static unsigned long previousMsecNext = currentMsec; static int count = 0; if (currentMsec - previousMsecLcd >= WAIT_LCD) { previousMsecLcd = currentMsec; count++; pLcd->println("count=" + String(count)); } if (currentMsec - previousMsecNext >= WAIT_NEXT) { SoundPlay(SOUND_TRANSITION, false, false); state = STATE_SURVEILLANCE; } } break; case STATE_SURVEILLANCE: { childDetection.update(currentMsec); if (childDetection.isFoundChild()) { pLcd->println("find child!"); SoundPlay(SOUND_TRANSITION, false, false); state = STATE_CHILD_FOUND; } } break; case STATE_CHILD_FOUND: { // do nothing } break; default: // do nothing break; } } ``` ## Eltres CLIP Viewer Lite のコンテンツにある「位置情報ペイロード送信プログラム」の Arduino 版をベースにしています。主な変更点としては GPS Payload の拡張領域に本システムの状態(State)を格納するようにしたことです。仕様上は 0x0 固定とのことでしたが、他の値を入れても問題なく Eltres アドオンボードから送信・CLIP Viewer Lite 側にて表示されることを確認しました。 ```c++:Eltres.h #ifndef __LNSB_ETRES_H__ #define __LNSB_ETRES_H__ #include <stdio.h> /* for sprintf */ #include <EltresAddonBoard.h> #include "Lcd.h" // PIN定義:LED(プログラム状態) #define LED_RUN PIN_LED0 // PIN定義:LED(GNSS電波状態) #define LED_GNSS PIN_LED1 // PIN定義:LED(ELTRES状態) #define LED_SND PIN_LED2 // PIN定義:LED(エラー状態) #define LED_ERR PIN_LED3 // プログラム内部状態:初期状態 #define PROGRAM_STS_INIT (0) // プログラム内部状態:起動中 #define PROGRAM_STS_RUNNING (1) // プログラム内部状態:終了 #define PROGRAM_STS_STOPPED (3) void EltresInitialize(Lcd* pLcd); void EltresSetupPayloadGps(); void EltresUpdate(); bool EltresIsGgaReceiving(); eltres_board_gga_info* EltresGetLatestGgaInfo(); void EltresSetStateForPayload(int state); #endif // __LNSB_ETRESS_H__ ``` ```c++:Eltres.cpp #include "Eltres.h" #include <TinyGPSPlus.h> typedef struct Parameter { int programSts; // プログラム内部状態 bool gnssRecevieTimeout; // GNSS電波受信タイムアウト(GNSS受信エラー)発生フラグ bool eventSendReady; // イベント通知での送信直前通知(5秒前)受信フラグ uint8_t payload[16]; // ペイロードデータ格納場所 eltres_board_gga_info lastGgaInfo; // 最新のGGA情報 bool isGgaReceiving = false; // GGA を受信し始めたら true、再び不正になったら false int stateForPayload; // 全体の State。拡張領域にセットする Lcd* lcd; } Parameter; static Parameter param; static void eltresEventCallback(eltres_board_event event) { Lcd* pLcd = Lcd::getInstance(); switch (event) { case ELTRES_BOARD_EVT_GNSS_TMOUT: // GNSS電波受信タイムアウト Serial.println("gnss wait timeout error."); param.lcd->println("gnss wait timeout error."); param.gnssRecevieTimeout = true; break; case ELTRES_BOARD_EVT_IDLE: // アイドル状態 Serial.println("waiting sending timings."); param.lcd->println("waiting sending timings."); digitalWrite(LED_SND, LOW); break; case ELTRES_BOARD_EVT_SEND_READY: // 送信直前通知(5秒前) Serial.println("Shortly before sending, so setup payload if need."); param.lcd->println("Shortly before sending, so setup payload if need."); param.eventSendReady = true; break; case ELTRES_BOARD_EVT_SENDING: // 送信開始 Serial.println("start sending."); param.lcd->println("start sending."); digitalWrite(LED_SND, HIGH); break; case ELTRES_BOARD_EVT_GNSS_UNRECEIVE: // GNSS電波未受信 Serial.println("gnss wave has not been received."); param.lcd->println("gnss wave has not been received."); digitalWrite(LED_GNSS, LOW); break; case ELTRES_BOARD_EVT_GNSS_RECEIVE: // GNSS電波受信 Serial.println("gnss wave has been received."); param.lcd->println("gnss wave has been received."); digitalWrite(LED_GNSS, HIGH); param.gnssRecevieTimeout = false; break; case ELTRES_BOARD_EVT_FAULT: // 内部エラー発生 Serial.println("internal error."); param.lcd->println("internal error."); break; } } static void ggaEventCallback(const eltres_board_gga_info *gga_info) { Lcd* pLcd = Lcd::getInstance(); Serial.print("[gga]"); if (!param.isGgaReceiving) { param.lcd->print("[gga]"); } param.lastGgaInfo = *gga_info; if (gga_info->m_pos_status) { // 測位状態 // GGA情報をシリアルモニタへ出力 Serial.print("utc: "); Serial.println((const char *)gga_info->m_utc); Serial.print("lat: "); Serial.print((const char *)gga_info->m_n_s); Serial.print((const char *)gga_info->m_lat); Serial.print(", lon: "); Serial.print((const char *)gga_info->m_e_w); Serial.println((const char *)gga_info->m_lon); Serial.print("pos_status: "); Serial.print(gga_info->m_pos_status); Serial.print(", sat_used: "); Serial.println(gga_info->m_sat_used); Serial.print("hdop: "); Serial.print(gga_info->m_hdop); Serial.print(", height: "); Serial.print(gga_info->m_height); Serial.print(" m, geoid: "); Serial.print(gga_info->m_geoid); Serial.println(" m"); if (!param.isGgaReceiving) { param.isGgaReceiving = true; param.lcd->println("ok."); } } else { // 非測位状態 // "invalid data"をシリアルモニタへ出力 Serial.println("invalid data."); param.isGgaReceiving = false; param.lcd->println("invalid data."); } } void EltresInitialize(Lcd* pLcd) { param.programSts = PROGRAM_STS_INIT; param.gnssRecevieTimeout = false; param.eventSendReady = false; param.isGgaReceiving = false; param.lcd = pLcd; // LED初期設定 pinMode(LED_RUN, OUTPUT); digitalWrite(LED_RUN, HIGH); pinMode(LED_GNSS, OUTPUT); digitalWrite(LED_GNSS, LOW); pinMode(LED_SND, OUTPUT); digitalWrite(LED_SND, LOW); pinMode(LED_ERR, OUTPUT); digitalWrite(LED_ERR, LOW); // ELTRES起動処理 eltres_board_result ret = EltresAddonBoard.begin(ELTRES_BOARD_SEND_MODE_1MIN, eltresEventCallback, ggaEventCallback); if (ret != ELTRES_BOARD_RESULT_OK) { // ELTRESエラー発生 digitalWrite(LED_RUN, LOW); digitalWrite(LED_ERR, HIGH); param.programSts = PROGRAM_STS_STOPPED; Serial.print("cannot start eltres board ("); Serial.print(ret); Serial.println(")."); param.lcd->print("cannot start eltres board ("); param.lcd->print(ret); param.lcd->println(")."); } else { // 正常 param.programSts = PROGRAM_STS_RUNNING; } } void EltresSetupPayloadGps() { String lat_string = String((char*)param.lastGgaInfo.m_lat); String lon_string = String((char*)param.lastGgaInfo.m_lon); int index; uint32_t gnss_time; uint32_t utc_time; // GNSS時刻(epoch秒)の取得 EltresAddonBoard.get_gnss_time(&gnss_time); // UTC時刻を計算(閏秒補正) utc_time = gnss_time - 18; // 設定情報をシリアルモニタへ出力 Serial.print("[setup_payload_gps]"); Serial.print("lat:"); Serial.print(lat_string); Serial.print(",lon:"); Serial.print(lon_string); Serial.print(",utc:"); Serial.print(utc_time); Serial.print(",pos:"); Serial.print(param.lastGgaInfo.m_pos_status); Serial.println(); param.lcd->print("[setup_payload_gps]"); param.lcd->print("lat:"); param.lcd->print(lat_string); param.lcd->print(",lon:"); param.lcd->print(lon_string); param.lcd->print(",utc:"); param.lcd->print(utc_time); param.lcd->print(",pos:"); param.lcd->print(param.lastGgaInfo.m_pos_status); param.lcd->println(); // ペイロード領域初期化 memset(param.payload, 0x00, sizeof(param.payload)); // ペイロード種別[GPSペイロード]設定 param.payload[0] = 0x81; // 緯度設定 index = 0; param.payload[1] = (uint8_t)(((lat_string.substring(index,index+1).toInt() << 4) + lat_string.substring(index+1,index+2).toInt()) & 0xff); index += 2; param.payload[2] = (uint8_t)(((lat_string.substring(index,index+1).toInt() << 4) + lat_string.substring(index+1,index+2).toInt()) & 0xff); index += 2; index += 1; // skip "." param.payload[3] = (uint8_t)(((lat_string.substring(index,index+1).toInt() << 4) + lat_string.substring(index+1,index+2).toInt()) & 0xff); index += 2; param.payload[4] = (uint8_t)(((lat_string.substring(index,index+1).toInt() << 4) + lat_string.substring(index+1,index+2).toInt()) & 0xff); // 経度設定 index = 0; param.payload[5] = (uint8_t)(lon_string.substring(index,index+1).toInt() & 0xff); index += 1; param.payload[6] = (uint8_t)(((lon_string.substring(index,index+1).toInt() << 4) + lon_string.substring(index+1,index+2).toInt()) & 0xff); index += 2; param.payload[7] = (uint8_t)(((lon_string.substring(index,index+1).toInt() << 4) + lon_string.substring(index+1,index+2).toInt()) & 0xff); index += 2; index += 1; // skip "." param.payload[8] = (uint8_t)(((lon_string.substring(index,index+1).toInt() << 4) + lon_string.substring(index+1,index+2).toInt()) & 0xff); index += 2; param.payload[9] = (uint8_t)(((lon_string.substring(index,index+1).toInt() << 4) + lon_string.substring(index+1,index+2).toInt()) & 0xff); // 時刻(EPOCH秒)設定 param.payload[10] = (uint8_t)((utc_time >> 24) & 0xff); param.payload[11] = (uint8_t)((utc_time >> 16) & 0xff); param.payload[12] = (uint8_t)((utc_time >> 8) & 0xff); param.payload[13] = (uint8_t)(utc_time & 0xff); // 拡張用領域設定, 仕様では 0 固定だが、他の値もセットできる模様 param.payload[14] = param.stateForPayload; // 品質設定 param.payload[15] = param.lastGgaInfo.m_pos_status; } void EltresUpdate(void) { switch (param.programSts) { case PROGRAM_STS_RUNNING: // プログラム内部状態:起動中 if (param.gnssRecevieTimeout) { // do nothing } else { digitalWrite(LED_ERR, LOW); } if (param.eventSendReady) { // 送信直前通知時の処理 param.eventSendReady = false; EltresSetupPayloadGps(); // 送信ペイロードの設定 EltresAddonBoard.set_payload(param.payload); } break; case PROGRAM_STS_STOPPED: // プログラム内部状態:終了 break; } } bool EltresIsGgaReceiving() { return param.isGgaReceiving; } eltres_board_gga_info* EltresGetLatestGgaInfo() { return &param.lastGgaInfo; } void EltresSetStateForPayload(int state) { param.stateForPayload = state; } ``` ## Accel 取得した値を見ていると、重力加速度の影響を受けているのが見えるので、ローパスフィルタに掛けています。 ```c++:Accel.h #ifndef __LNSB_ACCEL_H__ #define __LNSB_ACCEL_H__ void AccelInitialize(); void AccelUpdate(); float AccelGetAx(); float AccelGetAy(); float AccelGetAz(); void AccelShowData(); #endif // __LNSB_ACCEL_H__ ``` ```c++:Accel.cpp #include "Accel.h" #include <BMI160Gen.h> #define ACCEL_I2C_ADDR 0x68 #define ACCEL_RANGE 2 // 加速度センサ範囲: 2,4,8,16 (G) #define ACCEL_RATE 200 // センサー出力更新周期: 25,50,100,200,400,800,1600 (Hz) const float alpha = 0.9; // Low Pass Filter // 重力加速度 float xGravity = 0; float yGravity = 0; float zGravity = 0; // 補正された加速度 float ax = 0; float ay = 0; float az = 0; static float convertFromRawToFloat(int aRaw) { return ACCEL_RANGE * ((float)(aRaw) / 32768.0); } void AccelInitialize() { BMI160.begin(BMI160GenClass::I2C_MODE, ACCEL_I2C_ADDR); BMI160.setAccelerometerRange(ACCEL_RANGE); BMI160.setAccelerometerRate(ACCEL_RATE); // 200Hz } void AccelUpdate() { int rx, ry, rz; // raw accel values (16bit) float frx, fry, frz; // raw accel values (float) BMI160.readAccelerometer(rx, ry, rz); frx = convertFromRawToFloat(rx); fry = convertFromRawToFloat(ry); frz = convertFromRawToFloat(rz); // 重力加速度を計算 xGravity = alpha * xGravity + (1 - alpha) * frx; yGravity = alpha * yGravity + (1 - alpha) * fry; zGravity = alpha * zGravity + (1 - alpha) * frz; // 補正された加速度を計算 ax = frx - xGravity; ay = fry - yGravity; az = frz - zGravity; } float AccelGetAx() { return ax; } float AccelGetAy() { return ay; } float AccelGetAz() { return az; } void AccelShowData() { Serial.println(String(ax, 6) + "," + String(ay, 6) + "," + String(az, 6)); } ``` ## Button ```c++:Button.h #ifndef __LNSB_BUTTON_H__ #define __LNSB_BUTTON_H__ class Button { public: Button(int pin); virtual ~Button() {} void initialize(); void update(unsigned long currentMsec); bool isPushed(); void clear(); private: bool m_Initialized; int m_Pin; unsigned long m_PreviousMsec; bool m_PrevButtonValue; bool m_IsPushed; }; #endif // __LNSB_BUTTON_H__ ``` ```c++:Button.cpp #include "Button.h" #include <Arduino.h> Button::Button(int pin) { m_Initialized = false; m_Pin = pin; m_PreviousMsec = 0; m_PrevButtonValue = HIGH; m_IsPushed = false; } void Button::initialize() { if (!m_Initialized) { m_Initialized = true; pinMode(m_Pin, INPUT_PULLUP); } } void Button::update(unsigned long currentMsec) { if (currentMsec - m_PreviousMsec <= 20) { return; } m_PreviousMsec = currentMsec; bool buttonValue = digitalRead(m_Pin); // 負論理なので逆 if (m_PrevButtonValue == HIGH && buttonValue == LOW) { m_IsPushed = true; } m_PrevButtonValue = buttonValue; } bool Button::isPushed() { return m_IsPushed; } void Button::clear() { m_IsPushed = false; } ``` ## IR 今回使用した赤外線センサーは起動してから値が安定するまで 30 秒程度かかるようなのですが、赤外線センサーが必要になる状態 (SURVEILLANCE 状態) までには確実に 30 秒経っているため、特にケアはしていません。 ```c++:IR.h #ifndef __LNSB_IR_H__ #define __LNSB_IR_H__ int IRReadValue(); #endif // __LNSB_IR_H__ ``` ```c++:IR.cpp #include "IR.h" #include <Arduino.h> static int analogPin = A0; int IRReadValue() { return analogRead(analogPin); } ``` ## DriveDetection 状態遷移図の項で示した平均のグラフは 200 のレコードでしたが、本番のコードでは 400 のレコードまで増やしています。これは誤検出を防ぐのが目的です。時間にすると 5ms * 400 = 2秒なので、デモ動画でも分かるようにちょっと判定に遅れが生じています。 現在地と走行開始地点間の距離の算出は TinyGPS++ (http://arduiniana.org/libraries/tinygpsplus/) という Arduino Library の distanceBetween 関数で求めています。こちらは2点それぞれの緯度経度を引数として取り、メートルで返すというものです。ただ、ELTRES では dddd.mm 形式で位置情報が返ってきますが、distanceBetween 関数は度数分の値を想定しているため、変換を行っています。一見うまく行っているようなのですが、計測データを眺めていると数メートル単位でずれが起きており、今回、しきい値は甘めの 10m としています。もしかしたら私の変換で問題が生じている可能性もあります。 ```c++:DriveDetection.h #ifndef __LNSB_DRIVE_DETECTION_H__ #define __LNSB_DRIVE_DETECTION_H__ class DriveDetection { public: static const int ELEMENT_NUMBER = 400; // 要素数 DriveDetection(); virtual ~DriveDetection() {} void initialize(); void update(unsigned long currentMsec); void setInitialPotition(); void setCurrentPosition(const char* lat, const char* lon); bool isStartDrive(); bool isEndDrive(); bool isReturnedInitialPosition(); // for debug float getAzPlusAvg(); float getAzMinusAvg(); double getDistance(); private: void calcAvg(float* plus, float* minus, float* plusAvg, float* minusAvg); double calcDegree(double raw); void getCalculatedPotition(double* lat, double* lon); bool m_Initialized; unsigned long m_PreviousMsec; // 加速度 Z のプラス成分を保持する配列 float m_AzPlus[ELEMENT_NUMBER]; int m_AzPlusIndex; // 加速度 Z のマイナス成分を保持する配列 float m_AzMinus[ELEMENT_NUMBER]; int m_AzMinusIndex; // 運転開始時の緯度・経度 double m_InitialLat; double m_InitialLon; // 現在の緯度・経度 double m_CurrentLat; double m_CurrentLon; // for debug float m_AzPlusAvg; float m_AzMinusAvg; double m_Distance; }; #endif // __LNSB_DRIVE_DETECTION_H__ ``` ```c++:DriveDetection.cpp #include "DriveDetection.h" #include "Accel.h" #include "Eltres.h" #include <Arduino.h> #include <TinyGPS++.h> const int WAIT_TIME_MSEC = 5; const float THRESHOLD_START_DRIVE = 0.02; const float THRESHOLD_END_DRIVE = 0.004; const int THRESHOLD_POSITION_METER = 10; DriveDetection::DriveDetection() { m_Initialized = false; m_PreviousMsec = 0; m_AzPlusIndex = 0; m_AzMinusIndex = 0; memset(m_AzPlus, 0, ELEMENT_NUMBER); memset(m_AzMinus, 0, ELEMENT_NUMBER); m_InitialLat = 0; m_InitialLon = 0; m_AzPlusAvg = 0; m_AzMinusAvg = 0; m_Distance = 0; } void DriveDetection::initialize() { if (!m_Initialized) { m_Initialized = true; } AccelInitialize(); } void DriveDetection::update(unsigned long currentMsec) { if (currentMsec - m_PreviousMsec <= WAIT_TIME_MSEC) { return; } m_PreviousMsec = currentMsec; AccelUpdate(); // AccelShowData(); float az = AccelGetAz(); if (az >= 0) { m_AzPlus[m_AzPlusIndex++] = az; if (m_AzPlusIndex >= ELEMENT_NUMBER) { m_AzPlusIndex = 0; } } else { m_AzMinus[m_AzMinusIndex++] = az; if (m_AzMinusIndex >= ELEMENT_NUMBER) { m_AzMinusIndex = 0; } } } // dddd.mm を度数形式に変換 double DriveDetection::calcDegree(double raw) { int degree = int(raw / 100); double minute = (raw - (degree * 100)) / 60; return degree + minute; } void DriveDetection::getCalculatedPotition(double* lat, double* lon) { eltres_board_gga_info *ggaInfo = EltresGetLatestGgaInfo(); double rawLat = String((char*)ggaInfo->m_lat).toDouble(); double rawLon = String((char*)ggaInfo->m_lon).toDouble(); *lat = calcDegree(rawLat); *lon = calcDegree(rawLon); Serial.println("Lat(raw) = " + String(rawLat, 8) + ", " + String(rawLon, 8)); } void DriveDetection::setInitialPotition() { getCalculatedPotition(&m_InitialLat, &m_InitialLon); Serial.println("Initial Lat = " + String(m_InitialLat, 8) + ", " + String(m_InitialLon, 8)); } void DriveDetection::setCurrentPosition(const char* lat, const char* lon) { double rawLat = String(lat).toDouble(); double rawLon = String(lon).toDouble(); m_CurrentLat = calcDegree(rawLat); m_CurrentLon = calcDegree(rawLon); Serial.println("Current Lat = " + String(m_CurrentLat, 8) + ", " + String(m_CurrentLon, 8)); } void DriveDetection::calcAvg(float* plus, float* minus, float* plusAvg, float* minusAvg) { float sumPlus = 0; float sumMinus = 0; for (int i = 0; i < ELEMENT_NUMBER; i++) { sumPlus += plus[i]; sumMinus += minus[i]; } *plusAvg = sumPlus / ELEMENT_NUMBER; *minusAvg = sumMinus / ELEMENT_NUMBER; } bool DriveDetection::isStartDrive() { calcAvg(m_AzPlus, m_AzMinus, &m_AzPlusAvg, &m_AzMinusAvg); if (m_AzPlusAvg >= THRESHOLD_START_DRIVE && m_AzMinusAvg <= (THRESHOLD_START_DRIVE * -1)) { return true; } return false; } bool DriveDetection::isEndDrive() { calcAvg(m_AzPlus, m_AzMinus, &m_AzPlusAvg, &m_AzMinusAvg); if (m_AzPlusAvg <= THRESHOLD_END_DRIVE && m_AzMinusAvg >= (THRESHOLD_END_DRIVE * -1)) { return true; } return false; } bool DriveDetection::isReturnedInitialPosition() { m_Distance = TinyGPSPlus::distanceBetween(m_InitialLat, m_InitialLon, m_CurrentLat, m_CurrentLon); Serial.println("distance=" + String(m_Distance, 8) + "m"); if (m_Distance <= THRESHOLD_POSITION_METER) { return true; } return false; } float DriveDetection::getAzPlusAvg() { return m_AzPlusAvg; } float DriveDetection::getAzMinusAvg() { return m_AzMinusAvg; } double DriveDetection::getDistance() { return m_Distance; } ``` ## ChildDetection ```c++:ChildDetection.h #ifndef __LNSB_CHILD_DETECTION_H__ #define __LNSB_CHILD_DETECTION_H__ class ChildDetection { public: static const int ELEMENT_NUMBER = 400; // 要素数 ChildDetection(); virtual ~ChildDetection() {} void initialize(); void update(unsigned long currentMsec); bool isFoundChild(); private: bool m_Initialized; unsigned long m_PreviousMsec; bool m_IsIrDetected; }; #endif // __LNSB_CHILD_DETECTION_H__ ``` ```c++:ChildDetection.cpp #include "ChildDetection.h" #include "IR.h" #include <Arduino.h> const int WAIT_TIME_MSEC = 100; const int THRESHOLD_IR_VALUE = 600; ChildDetection::ChildDetection() { m_Initialized = false; m_PreviousMsec = 0; m_IsIrDetected = false; } void ChildDetection::initialize() { if (!m_Initialized) { m_Initialized = true; } } void ChildDetection::update(unsigned long currentMsec) { if (currentMsec - m_PreviousMsec <= WAIT_TIME_MSEC) { return; } m_PreviousMsec = currentMsec; if (IRReadValue() >= THRESHOLD_IR_VALUE) { m_IsIrDetected = true; } } bool ChildDetection::isFoundChild() { return m_IsIrDetected; } ``` ## Sound こちらは Spresense の Arduino 用のサンプルスケッチ (Audio / application / Player) をベースにしています。主な変更点としてはループ再生に対応したこと、そして終了時に即時終了するかどうかを指定できるようにしたことです。というのも、時間の短い効果音を AS_STOPPLAYER_NORMAL で終了させるとバッファを出力する前に終了してしまうからです。 ```c++:Sound.h #ifndef __LNSB_SOUND_H__ #define __LNSB_SOUND_H__ #include <Audio.h> #include <SDHCI.h> #define SOUND_BUZZER 0 #define SOUND_TRANSITION 1 void SoundInitialize(SDClass* sd); void SoundPlay(int sound, bool isLoop, bool isStopImmediately); void SoundUpdate(unsigned long currentMsec); bool SoundIsPlaying(); void SoundStop(); void SoundFinalize(); #endif // __LNSB_SOUND_H__ ``` ```c++:Sound.cpp #include "Sound.h" const int WAIT_TIME_MSEC = 40; const int VOLUME_DB = -10; const char* SOUND_FILES[] = { "SOUND/Buzzer.mp3", // SOUND_BUZZER "SOUND/Transition.mp3", // SOUND_TRANSITION }; AudioClass *theAudio = AudioClass::getInstance(); SDClass *pSd = NULL; File myFile; static unsigned long previousMsec = 0; typedef struct Parameter { int sound; bool isPlaying; bool isLoop; bool isStopImmediately; } Parameter; static Parameter param = { 0, false, false, false }; bool ErrEnd = false; static void audio_attention_callback(const ErrorAttentionParam *atprm) { puts("Attention!"); if (atprm->error_code >= AS_ATTENTION_CODE_WARNING) { ErrEnd = true; } } void SoundInitialize(SDClass* sd) { pSd = sd; theAudio->begin(audio_attention_callback); puts("initialization Audio Library"); /* Set clock mode to normal */ theAudio->setRenderingClockMode(AS_CLKMODE_NORMAL); /* Set output device to speaker with first argument. * If you want to change the output device to I2S, * specify "AS_SETPLAYER_OUTPUTDEVICE_I2SOUTPUT" as an argument. * Set speaker driver mode to LineOut with second argument. * If you want to change the speaker driver mode to other, * specify "AS_SP_DRV_MODE_1DRIVER" or "AS_SP_DRV_MODE_2DRIVER" or "AS_SP_DRV_MODE_4DRIVER" * as an argument. */ theAudio->setPlayerMode(AS_SETPLAYER_OUTPUTDEVICE_SPHP, AS_SP_DRV_MODE_LINEOUT); /* * Set main player to decode stereo mp3. Stream sample rate is set to "auto detect" * Search for MP3 decoder in "/mnt/sd0/BIN" directory */ err_t err = theAudio->initPlayer(AudioClass::Player0, AS_CODECTYPE_MP3, "/mnt/sd0/BIN", AS_SAMPLINGRATE_AUTO, AS_CHANNEL_STEREO); /* Verify player initialize */ if (err != AUDIOLIB_ECODE_OK) { printf("Player0 initialize error\n"); exit(1); } } void SoundPlay(int sound, bool isLoop, bool isStopImmediately) { if (param.isPlaying) { return; } param.sound = sound; param.isLoop = isLoop; param.isStopImmediately = isStopImmediately; // Open file placed on SD card myFile = pSd->open(SOUND_FILES[param.sound]); /* Verify file open */ if (!myFile) { printf("File open error\n"); exit(1); } printf("Open! 0x%08lx\n", (uint32_t)myFile); /* Send first frames to be decoded */ err_t err = theAudio->writeFrames(AudioClass::Player0, myFile); if ((err != AUDIOLIB_ECODE_OK) && (err != AUDIOLIB_ECODE_FILEEND)) { printf("File Read Error! =%d\n",err); myFile.close(); exit(1); } puts("Play!"); theAudio->setVolume(VOLUME_DB); theAudio->startPlayer(AudioClass::Player0); param.isPlaying = true; } void SoundUpdate(unsigned long currentMsec) { if (!param.isPlaying) { return; } if (currentMsec - previousMsec <= WAIT_TIME_MSEC) { return; } previousMsec = currentMsec; // Send new frames to decode in a loop until file ends int err = theAudio->writeFrames(AudioClass::Player0, myFile); // Tell when player file ends if (err == AUDIOLIB_ECODE_FILEEND) { printf("Main player File End!\n"); SoundStop(); if (param.isLoop) { SoundPlay(param.sound, param.isLoop, param.isStopImmediately); } return; } // Show error code from player and stop if (err) { printf("Main player error code: %d\n", err); goto stop_player; } if (ErrEnd) { printf("Error End\n"); goto stop_player; } // Don't go further and continue play return; stop_player: SoundFinalize(); } bool SoundIsPlaying() { return param.isPlaying; } void SoundStop() { // AS_STOPLAYER_NORMAL で即時停止 theAudio->stopPlayer(AudioClass::Player0, param.isStopImmediately ? AS_STOPPLAYER_NORMAL : AS_STOPPLAYER_ESEND); myFile.close(); param.isPlaying = false; } void SoundFinalize() { // AS_STOPLAYER_NORMAL で即時停止 theAudio->stopPlayer(AudioClass::Player0, AS_STOPPLAYER_NORMAL); myFile.close(); theAudio->setReadyMode(); theAudio->end(); exit(1); } ``` ## LNSB_Server サーバ側プログラムは Python スクリプトとなっております。引数としては CLIP Viewer Lite の id, password と LINE Notify のアクセストークンを指定します。 今回は下記のような事情により、スクレイピングを行うプログラムになっております。ブラウザ操作のライブラリとしては Selenium を使用しています。 - ELTRES アドオンボードはモニター提供を受けているもの(感謝!) - ただし、モニター提供者は API が利用できないため、直接ペイロードを取得することはできない。それに気づいたのがコンテスト終了が近づいてきた頃だった - 今回はスクレイピングにより CLIP Viewer Lite からペイロード情報を取得することとした ```python:LNSB_Server.py import argparse import requests from datetime import datetime, timedelta from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from time import sleep executable_path = "./chromedriver.exe" clip_viewer_url = "https://clip-viewer-lite.com/" line_notify_url = 'https://notify-api.line.me/api/notify' driver = "" username = "" password = "" token = "" # State STATE_INITIALIZE = 0x0 # 初期化 STATE_POSITIONING = 0x1 # 測位中 STATE_WAIT_FOR_DRIVING = 0x2 # 走行待機 STATE_ON_DRIVING = 0x3 # 走行中 STATE_STOPPED = 0x4 # 停車 STATE_WAIT_FOR_BUTTON = 0x5 # ボタン押下待機 STATE_WAIT_FOR_EXIT = 0x6 # 退出待ち STATE_SURVEILLANCE = 0x7 # 監視 STATE_CHILD_FOUND = 0x8 # 子供を発見した messageList = [ 'デバイスの初期化中です', 'GNSS の測位中です', '運転開始前です', '走行中です', '停車しています', 'ボタンの押下を待機しています', '運転手の退出を待っています', '生徒さんの監視を行っています', '車内に生徒さんの存在を検知しました。直ちに車両に向かい、確認をしてください。' ] # 最後に読み込んだペイロードの送信時間 lastPayloadTime = datetime.now() # 最後に処理した State lastProcessedState = -1 def setup(): global driver, username, password, token parser = argparse.ArgumentParser() parser.add_argument('username', type=str, help='username for CLIP Viewer Lite') parser.add_argument('password', type=str, help='password for CLIP Viewer Lite') parser.add_argument('token', type=str, help='token is API Token for LINE Notify') args = parser.parse_args() username = args.username password = args.password token = args.token chrome_service = Service(executable_path=executable_path) driver = webdriver.Chrome(service=chrome_service) def sign_in(): global driver driver.get(clip_viewer_url) # 少し待たないと shadow_root 以下の要素が取得できず、エラーになる sleep(3) e_sign_in = driver.find_element(By.TAG_NAME, 'amplify-sign-in') shadow_root = e_sign_in.shadow_root e_username = shadow_root.find_element(By.CSS_SELECTOR, 'input#username') e_password = shadow_root.find_element(By.CSS_SELECTOR, 'input#password') e_username.send_keys(username) e_password.send_keys(password) e_password.send_keys(Keys.ENTER) def expected_page_is_open(): global driver title = driver.find_element(By.CLASS_NAME, 'title') try: return title == "CLIP Viewer Lite" except: return False def disable_update_button(): # 自動更新を無効化 update_button = driver.find_element(By.XPATH, '//input[@class="jss4 MuiSwitch-input"]') update_button.click() def get_latest_payload(): global lastPayloadTime, lastProcessedState # 最新のパケットの送信時刻・ペイロード e_send_date_time = driver.find_element(By.XPATH, '//div[@class="MuiDataGrid-cell MuiDataGrid-cellLeft" and @data-field="sendDateTime" and @data-rowindex="0" and @aria-colindex="0"]') e_payload = driver.find_element(By.XPATH, '//div[@class="MuiDataGrid-cell MuiDataGrid-cellLeft" and @data-field="payload" and @data-rowindex="0" and @aria-colindex="2"]') # 不可視設定のプロパティは text で取得できないので textContent 属性で取得 sendDateTimeStr = e_send_date_time.get_attribute('textContent') payloadStr = e_payload.get_attribute('textContent') print('sendDateTime=' + sendDateTimeStr) print('payload=' + payloadStr) sendDateTime = datetime.strptime(sendDateTimeStr, '%Y-%m-%d %H:%M:%S') print(sendDateTime) # 新しいペイロードが来ていたら処理続行 if sendDateTime > lastPayloadTime: lastPayloadTime = sendDateTime # ペイロードから識別コードを取得 idCode = int(payloadStr[0:2], 16) # GPS ペイロードでなかったら何もしない if idCode != 0x81: print('This Payload is not GPS Payload Type ({}). So do nothing.'.format(hex(idCode))) return print('This Payload is GPS Payload Type') # ペイロードから State を取得 state = int(payloadStr[-4:-2], 16) if state != lastProcessedState: lastProcessedState = state print('state=' + hex(state)) print(messageList[state]) send_line_notify(messageList[state]) else: # 前回処理済みの State だったら何もしない print('state {} was already processed. So skipped.'.format(hex(state))) def send_line_notify(message): headers = {'Authorization': 'Bearer {}'.format(token)} data = {'message': '{}'.format(message)} response = requests.post(line_notify_url, headers=headers, data=data) print(response) def main(): print('now=' + str(lastPayloadTime)) setup() sign_in() # 一定秒数待ち、ちゃんと目的のページが開かれているかチェック # JavaScript で制御されているためか、WebDriverWait が正常に動作しない sleep(10) expected_page_is_open() send_line_notify('CLIP Viewer Lite へのサインインが成功しました。') disable_update_button() while True: get_latest_payload() driver.refresh() sleep(20) expected_page_is_open() driver.quit() if __name__ == "__main__": main() ``` ## その他 その他、システム構成図にないソフトウェア処理部に Lcd、Debug があります。それぞれデバッグ用途に使っていたものです。 ```c++:Lcd.h #ifndef __LNSB_LCD_H__ #define __LNSB_LCD_H__ #include <SPI.h> #include <Adafruit_ILI9341.h> #define TFT_CS -1 #define TFT_RST 8 #define TFT_DC 9 class Lcd : public Adafruit_ILI9341 { private: static Lcd* s_pLcd; private: Lcd(); public: virtual ~Lcd() {} static Lcd* getInstance(); void initialize(); void update(); private: bool m_Initialized; }; #endif // __LNSB_LCD_H__ ``` ```c++:Lcd.cpp #include "Lcd.h" Lcd* Lcd::s_pLcd = NULL; Lcd::Lcd() : Adafruit_ILI9341(&SPI, TFT_DC, TFT_CS, TFT_RST) { m_Initialized = false; } Lcd* Lcd::getInstance() { if (s_pLcd == NULL) { s_pLcd = new Lcd(); } return s_pLcd; } void Lcd::initialize() { if (!m_Initialized) { m_Initialized = true; this->begin(40000000); this->setRotation(3); this->fillScreen(ILI9341_BLACK); this->setCursor(0, 0); this->setTextColor(ILI9341_WHITE); this->setTextSize(1); this->setTextWrap(true); } } void Lcd::update() { int16_t y = s_pLcd->getCursorY(); if (y >= 240) { s_pLcd->fillScreen(ILI9341_BLACK); s_pLcd->setCursor(0, 0); } } ``` ```c++:Debug.h #ifndef __LNSB_DEBUG_H__ #define __LNSB_DEBUG_H__ #include "DriveDetection.h" #include "Lcd.h" void ShowAzAvgDataOnLcd(DriveDetection* driveDetection, Lcd* lcd, unsigned long currentMsec); void ShowDistanceOnLcd(DriveDetection* driveDetection, Lcd* lcd, unsigned long currentMsec); #endif // __LNSB_DEBUG_H__ ``` ```c++:Debug.cpp #include "Debug.h" #include <Arduino.h> static unsigned long previousMsecAzAvg = 0; static unsigned long previousMsecDistance = 0; void ShowAzAvgDataOnLcd(DriveDetection* driveDetection, Lcd* lcd, unsigned long currentMsec) { char buff[128]; if (currentMsec - previousMsecAzAvg <= 500) { return; } previousMsecAzAvg = currentMsec; sprintf(buff, "%f, %f", driveDetection->getAzPlusAvg(), driveDetection->getAzMinusAvg()); lcd->println(buff); } void ShowDistanceOnLcd(DriveDetection* driveDetection, Lcd* lcd, unsigned long currentMsec) { char buff[64]; if (currentMsec - previousMsecDistance <= 500) { return; } previousMsecDistance = currentMsec; bool isReturn = driveDetection->isReturnedInitialPosition(); sprintf(buff, "isReturn=%d, distance=%f", isReturn, driveDetection->getDistance()); lcd->println(buff); } ``` # まとめ 今回は置き去り防止のためのシステム「Leave No Student Behind」の Spresense 版の開発を行いました。運転の開始と終了の検出をすることで適切なタイミングで運転手へ警告音を再生し、車内の検査を促すことができるようになりました。また、運転手が見逃した場合を見越して退出後も車内を監視し、検出する仕組みも導入しました。検出したとき、ELTRES 通信経由でシステム登録者に通知を送るところも達成しました。 以上より、当初の目標であった置き去り装置とシステムを実現できたと考えております。 # 今後の課題 今後の課題としては以下のようなものがあります。 ## EV (電気自動車) への対応について 今回はエンジンのピストン振動に着目してエンジン始動・停止を検出しましたが、モーターで駆動する EV ではどうすれば良いのか?運転したことがないので未知数ですが、挑戦することでまた気付きがありそうです。 ## 人検出について 焦電型の赤外線センサーは熱の変化を検知するものであるため、対象がずっと停止している場合には検知できません。そのため、カメラと AI を使った人検出まで挑戦したいところです。 ただ、現状のコードで AI ライブラリとカメラライブラリの初期化を行うと "Out Of Memory" でプログラムが終了してしまいました。そのため、まず各状態で必要なライブラリだけを使うようにし、不要なメモリを開放するなどのリファクタリングが必要です。 また、レーダーによってカメラに映らないところにいる人も検出できると思われますので、レーダーによる人検出も挑戦したいことの一つです。3点セット(赤外線、カメラ + AI、レーダー)での車内監視が実現すれば人検出のシステムとして最強かもしれません。 ## スクレイピング手法について LNSB Server で使っているスクレイピング手法ですが、CLIP Viewer Lite に反映されるのは送信後 1 分程度経ってからなので、リアルタイム性からするとやはり API を利用したデータ取得にはかないません (API による取得は即時最新のものが取れると想定)。何より CLIP Viewer Lite 側の仕様変更に弱く、簡単にデータ取得ができなくなってしまうところもスクレピング手法の弱点です。 # 最後に - この度はコンテストの開催をありがとうございました。今回の機会を利用して、がっつり Spresense に触れることができました。お尻に火が付かないとやらない性格なので・・・。 # クレジット 装置における状態遷移時の効果音、停車時の効果音の音源はオトロジック (https://otologic.jp/) (CC BY 4.0) よりお借りしました。 - 状態遷移時の効果音 : アクセント43 - 停車時の効果音の音源 : 警告音 サイレン05