Raspberry Pi3+ドコデモ人感センサー+Vue.jsで「会議室の空室ディスプレイ」をつくる #16

令和元年のゴールデンウィークは恒例の「ものづくり一人旅」に行ってきた。

今年は島根県の隠岐諸島(海士町とか)をのんびり旅しながら、ラズパイとセンサーを使ってうちの会社で使う「会議室の空室ディスプレイ」を作ってきたので作り方を紹介する。

「会議室見に行く」をなくしたかった

オフィスで会議するとき、こんなことがよくある。

「いまちょっとMTGしよう」 会議室見に行く 「どこもあいてない!」
「あの部屋への来客がきた」 会議室見に行く 「まだ使ってた!」
「あの人まだ会議してるかな?」 会議室見に行く 「まだしてそう!」

こういう「会議室見に行く」をなくしたかった。その会議室が使用中かどうかリアルタイムにわかれば、いちいち会議室に見に行く必要がなくなる

ついでに、

「あの部屋なかに人いる??」
「勢いよく飛び込んだら重役会議中で気まずい・・」

などのよくある課題も解決できる!

最近人が増えて会議室も埋まりがちで、だいたい1日に1回は「会議室見に行く」「そして帰ってくる」が発生している。

なんと年間120万円の大損失!?

試しに1年間でどれぐらいの損失になっているか計算すると・・

①1日で1分「会議室見に行く」が発生とする。
②60名なので1日で1時間の損失
④1ヶ月だと2日分の損失。
③1人の1日の生産力は約5万円とすると・・
⑤1ヶ月で10万円、1年で120万円の損失!

これは大変。早くなんとかしなくては! 全然関係ないけど写真は社内の紙ひこうき大会の様子。無駄な時間を節約して、紙ひこうきであそぼう笑。

つくりかた【ダイジェスト】

空室ディスプレイを作るのに必要なのは、この7ステップ。

①ドコデモ人感センサーを動かす
②センサーのJSON APIを叩く
③CSSで画面をデザイン
④Vue.js+センサーAPIで空室案内表示
⑤ラズパイのセットアップ
⑥ラズパイ上で空室案内表示
⑦ラズパイの自動起動設定

センサーの情報がディスプレイに表示されるまでのデータの流れはこういう感じ。

★ドコデモ人感センサー

↓<人を感知>どこでもセンサー社のクラウドにデータをアップ

★クラウドサービス

↓<JSから随時リクエスト>センサーデータをラズパイのJSに返す

★ラズパイ(JS)

↓<常時表示>5分無反応なら空室と表示。10秒毎に更新

★小型ディスプレイ

————

ちなみに今回つかった道具たち。窓の向こうではニワトリが騒がしい笑。

①ドコデモ人感センサーを動かす

センサーをいろいろと探してみたところ、プラネットコミュニケーション社のどこでも人感センサー WS-USB02-PIRが良さそう。

理由1:シンプルで値段も手頃
理由2:センサーデータをクラウドに1ヶ月保管(無料!)
理由3:API使える(2019.4リリース。ギリギリ間に合った!)

USBでコンセントにさせるシンプルな構造。(隠岐ジオパークキャンプ場にて笑)

電源に指したらセンサーのIPアドレスにブラウザからWifiアクセス(センサーをルータと見立てる感じ)。実際に使うWifi環境のSSID/PASSを入力する。開発中はいちいち変えるのが面倒なので自分のiPhoneのテザリングWifiを指定。あとはコンセントにさすだけで自動でセンサースタート。しばらく計測するとクラウドの管理画面で検知ログがグラフで見れる。いいね!

②センサーのJSON APIを叩く

さあ次はさっそくセンサーが感知したログをAPIで取得する。このAPIの動作確認がまあまあ大変だった。センサーのメーカーはソフト開発が苦手なのか、管理画面がワードプレスだったり、API資料が10行ぐらいの説明で済まされてたり、その取得方法の例が間違ってたり、エラーのときレスポンスにエラーメッセージはなく[]だけだったりと、なかなかの突き放し方・・。だがそれはそれで楽しいからよし笑。

APIをブラウザで叩いてちゃんとレスポンスが帰ってくるまで何が正しいかわからず意外に時間がかかってしまったので、後発組のために正しい使用例を下記に記載しておく。(%22は”, %20はスペースのエスケープ)

リクエストURL
https://svcipp.planex.co.jp/api/get_data.php?type=%22WS-USB02-PIR%22&mac=%2224:72:60:40:**:**%22&from=%222019-04-30%2011:00:00%22&to=%202019-04-30%2012:00:00%22&token=%2211e1277b5e1619561ccd7a1b9977****%22

レスポンスデータ
[
[“2019-04-30 11:00:00”, “24”]
,[“2019-04-30 11:00:06”, “24”]
,[“2019-04-30 11:00:10”, “24”]
,[“2019-04-30 11:00:16”, “24”]
]

③CSSで画面をデザイン

動作確認も出来たところでいよいよ開発開始。まずはCSSで画面をデザイン。遠くの人からでも見えるように、文字を最大限おおきく表示。またそれでも読めないかも知れないので、文字の背景を白で塗る。

この背景はゲージになっていて5分かけて下がっていくようにした。このセンサーは厳密に「入室」「退室」が取れるわけじゃなくて、人の動きを検知するだけ。実際に会議室で試したところ、人がいたとしてもあんまり動かないと検知しないことがわかった。それでも、だいたい5分に1回は人が動いて検知することが経験的に判ったので、「直近5分反応がなかったら空室とみなす」という仕様にした。

ゲージが減っているときに空室かどうか明確に判断はできないけど、それも含めてユーザに伝わる仕組みだ。このあたりハードの仕様や性能限界に合わせて最適なUI/UXを考えて、それをデザインで表現して、説明せずともシンプルで使いやすくするセンスがとても大事だと思う

フォントはBebas Neueっていう電光掲示板っぽいおしゃれフリーフォントを採用。

さらに、この手のシステムはまったく動きがないと、フリーズしてないか?なんらかのエラーが裏で起きてないか?不安になったりするので、常にちょっとでも動きがあったほうが良い。ということで、なんにも反応を検知してないときは、時計を表示した。

④Vue.js+センサーAPIで空室案内表示

いよいよVue.jsを使って、センサーAPIから取得した空室状況を画面に表示する。react.jsとか素のjsでも良かったけど、コードがシンプルに書けて学習が簡単そうで流行ってるという理由でVue.jsに決定。今回のソースは200行に収まっていて本当にシンプル!

Vue.jsのいいところは、モデル(HTML)+画面デザイン(CSS)+ロジック(JS)を1つの「コンポーネント」という単位で扱えて、再利用するときにまるごと再利用できてコードがシンプルになりやすい。今回まさに1つの部屋のコンポーネントをつくってそれを5回繰り返して5部屋の表示処理を実現した。

さらにWEBサーバをたてなくてもローカルのブラウザで動作するのも楽。ただしPCのChromeからアクセスしようとすると、CORSというJSのエラー。どうやらローカルのhtmlファイルからAJaxで外部サーバにアクセスしようとするとセキュリティのエラーになるらしい。これはAllow-Control-Allow-OriginというChromeのオプションでOFFにすることができる。

ソースは下記の通り!

★AiThema10.html (メインプログラム)


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="google" content="notranslate">
    <title>空室案内:アイテマテン!</title>
    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.6/moment.js"></script>
    <link rel="stylesheet" type="text/css" href="AiThema.css"></link>
</head>
<body>
  <div class="block" id="roomViewer">
      <room-viewer v-for="(room, index)  in rooms" :index="index"></room-viewer>
  </div>
  <div class="clock" id="nowClock" v-if="isEmpty" >
    {{NowTime}}
  </div>
</body>
<script>

  /* -----------------------------------------------共通初期設定----------------------------------------------- */
  const emptyTime       ='300';   //何秒たったら空室とみなすか
  const timerInterval   = 1000;   //何ミリ秒ごとに更新するか
  const requestInterval = 10;     //何秒ごとにサーバに確認するか

  //接続先の部屋一覧とデバイスの接続設定。最後の2つは1.最新検知日時,2.最新アクセス日時
  var rooms = [
    {'id': "S", 'mac': "24:72:60:40:**:**", 'token': "11e1277b5e1619561ccd7a1b9977****",'hit_date':"",'update_date':""},
    {'id': "T", 'mac': "24:72:60:40:**:**", 'token': "d6a0e1de99d2bbae39cd6cf043e0****",'hit_date':"",'update_date':""},
    {'id': "B", 'mac': "24:72:60:40:**:**", 'token': "38caa196230f2b19d9a6ac4397d5****",'hit_date':"",'update_date':""},
    {'id': "M", 'mac': "24:72:60:40:**:**", 'token': "1427bef41f36d90efb95aae0bda2****",'hit_date':"",'update_date':""},
    {'id': "C", 'mac': "24:72:60:40:**:**", 'token': "94781891187c4cbd72fe8a510a05****",'hit_date':"",'update_date':""}
  ]

  //センサーAPIのURL
  var url = "https://svcipp.planex.co.jp/api/get_data.php?type=WS-USB02-PIR&mac=$mac&from=$fromDate&to=$toDate&token=$token"

  /* ----------------------------メインコンポーネントの設定。APIへアクセスし空き状況表示------------------------------ */
  Vue.component('room-viewer', {
    props: ['index'],

    /* ----------------共通変数の宣言---------------- */
    data: function () {
      return{
        roomName:[],            //表示する部屋名(1文字)
        pastSecond:emptyTime,   //前回反応してから何秒だったか? デフォでは空室とみなす
        reloadTimer: null,      //定期的に画面をリロードするタイマー
        lastRequest:''          //前回リクエストした時刻を保管。リクエストのインターバルを取るため
      }
    },

    /* ------------初期化・タイマーのセット------------ */
    created: function() {

      let self = this;

      Vue.set(self.roomName, self.index, rooms[self.index].id); //部屋名を表示 (エラーがでたら部屋名は?になる)
      console.log("roomName:"+this.roomName[this.index]);

      this.reloadTimer = setInterval(function() {self.checkStatus()}, timerInterval);  //定期的に状況確認を呼び出す

    },

    /* ----------------メソッド宣言---------------- */
    methods: {

      /****** サーバへ検知状況をチェックするメソッド ******/
      checkStatus: async function(){

        var now =  moment(new Date).format('YYYY-MM-DD HH:mm:ss');

        //前回の検知から何秒経過した?
        pastSecond =  moment(now).diff(moment(rooms[this.index].hit_date),'seconds');
        this.statusView();

        //リクエスト間隔が設定値に達しなければ何もせず待つ
        if(this.lastRequest!=''
            & moment(now).diff(moment(this.lastRequest),'second') < requestInterval){
              return;
        }

        this.lastRequest = now;

        //サーバ接続処理開始

        var sensorData = [];  //センサーから反応時刻を取得
        var reqUrl = url;     //センサーのAPI URL

        //リクエスト用のデバイス情報
        reqUrl = reqUrl.replace('$mac'  ,rooms[this.index].mac);
        reqUrl = reqUrl.replace('$token',rooms[this.index].token);

        //今から10分前までのログを取得 urlに設定
        var m = moment(new Date).add(-9, "hours");  //なぜかセンサーが9時間前のutcなので調整
        reqUrl = reqUrl.replace( '$toDate', m.format('YYYY-MM-DD HH:mm:ss') );
        reqUrl = reqUrl.replace( '$fromDate', m.add(-10, "minutes").format('YYYY-MM-DD HH:mm:ss') );

        console.log(reqUrl);

        try{
          //センサーのAPIにリクエスト
          var res = await axios.get(reqUrl);
          sensorData = res.data;
          console.log("resdata:"+sensorData);

          Vue.set(this.roomName, this.index, rooms[this.index].id); //部屋名を改めて指定(?かもしれないので)
          console.log("roomName:"+this.roomName[this.index]);


        } catch (error) {
          Vue.set(this.roomName, this.index, '?');  //エラーの場合ディスプレイに?とだす
          console.log("エラー発生..."+error);
        }

        //最新検知日時が取れれば、保存する
        if(sensorData.length!=0){       //何らかのデータが帰ってくれば格納
          console.log("api_lastdata:"+sensorData[sensorData.length-1][0]);
          rooms[this.index].update_date = now;

          //UTF->JST変換して格納
          rooms[this.index].hit_date = moment(sensorData[sensorData.length-1][0])
                                        .add(9, "hours").format('YYYY-MM-DD HH:mm:ss');
        }


      },

      /****** 空室状況を画面に表示するstyleの調整 ******/
      statusView: function(){

        //console.log("Last Hit:"+rooms[this.index].hit_date+" pastSecond:"+this.pastSecond);

        if(rooms[this.index].hit_date!=""){
          var hitMoment = moment(rooms[this.index].hit_date); //前回検知時間
          this.pastSecond = moment(new Date()).diff(hitMoment, 'seconds');  //何秒経過した?
        }
        var blackPer = (this.pastSecond * 100) / emptyTime;  //進捗率100%に治す
        blackPer = Math.floor(blackPer);                     //整数になおす
        blackPer = Math.min(blackPer,100);                   //最大100%に丸める
        //console.log("blackPer:"+blackPer);

        var charColor = "red"; //0〜25%

        if(blackPer>=25&&blackPer<50){ //〜50%
          charColor = "#FF4500";
        }
        else if(blackPer>=50&&blackPer<75){ //〜75%
          charColor = "#FF7F50";
        }
        else if(blackPer>=75&&blackPer<100){
          charColor = "#FFA07A";
        }
        else if(blackPer==100){
          charColor = "white";
        }
        return{
          background: 'linear-gradient(black '+blackPer+'%, white '+(100-blackPer)+'%)',
          color: charColor
        }
      }
    },

    /* ----------------表示設定---------------- */
    template:
      '<div class="cell_basic" v-bind:style="statusView()">'+
        '{{this.roomName[this.index]}}'+
      '</div>'
  })

  /* --------------------------------Vueの宣言-------------------------------- */
  new Vue({  //メイン
    el: '#roomViewer',
  })

  new Vue({  //時計
      el: '#nowClock',
      data: {
        NowTime: '', //testValを定義
        isEmpty: ''
      },
      created:  function () {
        let self = this;
        setInterval(function(){self.setDate()}, 500);
      },
      methods: {
        setDate: function(){
           this.NowTime = moment(new Date).format('HH:mm:ss') //現在時刻を返す
           this.isEmpty = true;
           for (var index in rooms) {
             if(rooms[index].hit_date!="" &
                moment(new Date).diff(moment(rooms[index].hit_date),'seconds') < emptyTime){
                  this.isEmpty = false;
                }
             }
        }
      }
    });
</script>
</html>

★AiThema.css

body {
    background-color:black;
    overflow:hidden;
    margin: 0px;
}

.block{
  display: table;
  width: 100vw;
  height: 100vh;
  text-align: center;
  font-size: 45vw;
  font-family:  "Bebas Neue";
}

.cell_empty{
  color : white ;
  display: table-cell;
  vertical-align: middle;
  border: 0.5px solid black;
}

.cell_basic{
  display: table-cell;
  vertical-align: middle;
  border: 0.5px solid black;
}

.clock{
  display: table;
  width: 100vw;
  height: 10vh;
  text-align: center;
  font-size: 5vw;
  font-family:  "Bebas Neue";
  color : white ;
  margin:  0px;
  position:  absolute;
  bottom: 2vh;
  z-index: 10;
}

※var rooms[]の設定とcssの.blockのfont sizeを変えるだけで、部屋が5個じゃなくても動作可能
(デザイン的には2〜7部屋ぐらいがちょうどよい)

⑤ラズパイのセットアップ

さあいよいよラズパイ上でのこのプログラムを動作させよう。

まずは、ラズパイ+外付けディスプレイを接続してWifiの設定しようとしたら問題発生!MACからラズパイにアクセスする方法がなにもない。。Wifiの設定はどうしても外付けキーボードで直接つないでやらないといけない。でもここは人口2000人の離島。。PCショップなんてどこにもない。途方に暮れているところ、民宿のおじさんがキーボードを持っていた!ベタベタでCtrlキーもなくなってたけど、全然問題なし。ありがとうおじさん。

1. まずWifiの設定

移動するごとに毎回設定するのは面倒なので、iPhoneのテザリングに指定。さらにホスト名を設定してipアドレスを毎回入れなくて良いように。

$ssh pi@raspberrypi.local

で同じネットワーク内にいればログインできる。簡単

2. 初期セットアップ

最新OSをインストールしたり、地域を設定したり。ネットで探すとたくさん記事がある。

3. ファイルをラズパイに配置

これもscpコマンドで簡単における。htmlとcssを置くだけ。

$scp -r AiThema10 pi@raspberrypi.local:/home/pi/Desktop/

4. fontのインストール

“Bebas Neue”フォントがラズパイ内にないのでインストール。apt-getでも登録されていないので、直にBebas Neueの配布サイトからダウンロードして.fontsフォルダに配置するだけ。簡単。

$scp BebasNeue-Regular.ttf pi@raspberrypi.local:/home/pi/.fonts/

これだけでラズパイ上での動作設定が完了!

5. ブラウザを起動する

下記のコマンドでchromeが起動される。

chromium-browser --noerrdialogs --kiosk --incognito --disable-web-security --user-data-dir --test-type /home/pi/Desktop/AiThema10/AiThema10.html

#--noerrdialogs エラーダイアログを表示しない
#--kiosk 全画面表示
#--incognito シークレットモードで起動
#--disable-web-security chromeのextension同様、ローカルからAjaxを読むため
#--user-data-dir 同上。この②個セット
#--test-type ヘッダーにwarningが出たりするのでそれが出ないように

⑦ラズパイの自動起動設定

あっという間に設定までが完了!最後にオフィスで使うための細かな設定。

1. OS起動時に自動起動

電源を入れるだけで誰でも使えるように、OS起動後に自動的にブラウザ起動して全画面表示にしたい。autostartファイルを編集すると、自動起動の設定ができる。ついでにマウスポインタも非表示時に。

マウス無効化モジュールをインストール

$sudo apt-get install unclutter

自動起動の設定ファイルを編集

$sudo vi .config/lxsession/LXDE-pi/autostart
#下記は無効化
#@lxpanel --profile LXDE-pi
#@pcmanfm --desktop --profile LXDE-pi
#@xscreensaver -no-splash
#@point-rpi

#スクリーンセーバーをオフに
@xset s off

# X serverをオフに
@xset -dpms

# DPMS (Display Power Management Signaling) をオフに
@xset s noblank

#マウスポインタを非表示
@unclutter

#自動起動
@chromium-browser --noerrdialogs --kiosk --incognito --disable-web-security --user-data-dir --test-type /home/pi/Desktop/AiThema10/AiThema10.html

2.深夜土日はOFFに

省エネのために、使う可能性が低いときはスリープモードにしておきたい。スリープ状態では、ブラウザを停止して、ディスプレイもOFFにする。再開したいときには再起動するだけでOK。cronに設定する。

$sudo crontab -e
#平日の朝10時に再起動
0 10 * * 1-5 /sbin/shutdown -r now

#平日の夜23時にブラウザ停止とディスプレイをスリープ
0 23 * * 1-5 pkill chromium &amp;&amp; sudo service lightdm stop

以上で完了! ちょくちょくハマったけど終わってみれば本当に簡単だった。良き時代!

社内に設定してみた

完成したので早速社内に設置。誰の机からもいまの会議室の空き状況がわかるようになり、いちいち見に行かなくて良くなった。ただし、「説明しなくても直感でわかるデザイン」ということで、特に説明なく置いていたら「気温の表示ですか?」と言われてしまった笑。。もうすこしUI改善の余地あり。また、個人的に世界一やさしいメッセージだと思う「人がいなくても水が流れることがあります by TOTO」と同じ原理で、誰もいないのに反応してしまうことがある。このあたりの誤認識をスルーするようなアルゴリズムはまだ改善の余地あり。

かかった費用は4万円ぐらい

全体的に思ってたより安い。原価4万円で年間120万円の節約に成功!

Raspberry Pi3 ×1 9,999円
ディスプレイ ×1 7,299円
ドコデモ人感センサー ×5 20,810円
延長コード ×5 3,590円

合計:41,690円

さいごに:ハードもソフトの時代へ

これまでソフトウェアエンジニアは家電とかハードについてはどこか遠くの存在だったけど、ラズパイやIoTデバイスの普及などによって簡単にリアルな空間で使える装置を作れるようになった。今回の開発でとくに簡単さを強く実感した。もうPCやスマホに収まっている時代ではない。

ソフトもハードも一体となって、かんたんなプログラミング感覚でリアルものづくりができる時代には、技術力よりもむしろ

①リアルなシーンや人の気持ちを理解してUI/UXにを設計するデザイン力
②とりあえずプロトを作ってみて良かったら事業化するビジネス力

などがとっても大事になってくる。

ということで当社でもそんなビジョンをもったエンジニア&クリエイターをまだまだ増やしていくので、興味のある方は会社HPから連絡まってます!

おまけ

隠岐ジオパークキャンプ場でリアルプログラムキャンプの様子笑。隣のテントのソロキャンパーと釣った魚をBBQして遊んだりチャリで離島を一周したりしながらの、年に一度の至福の時間だった。

ではでは!

菅澤 英司
菅澤 英司
bravesoft CEO&つよつよエンジニア社長です。よろしく!