obniz-nobleでBLEスキャンをグラフ表示してみた

obniz-nobleは手軽に使えていい感じですね！

今回は、ブラウザからObniz BoardまたはobnizOSを書き込んだESP32にobniz-nobleで接続して、周辺のBLEデバイスのRSSIをグラフ表示してみます。
完成図はこんな感じです。(※撮影のため、obniz idやBLEアドレスをマスク化しています。)

![※撮影のため、obniz idやBLEアドレスをマスク化しています。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/d19c8904-35a2-a431-28d5-d470783686e8.gif)

ブラウザから開いたのち、obniz idを入力して、接続ボタンを押下すると、1秒間隔でRSSIを取得して時系列にグラフ表示してくれます。

本ツールを作成したのは、Androidでも同様のツールがあるのですが、最近AndroidのBLEスキャン間隔が定期的に長くなってしまって使いにくくなったためです。
また、ブラウザで見ると大きな画面で確認できるし、M5StickCであれば、バッテリを積んでいるのでBLEスキャナーとして持ち歩くことができます。

試しに動かせるようにGitHubに上げておきました。
　https://github.com/poruruba/ble_scanner 

以下をブラウザから開くことで動かすことができます。
　https://poruruba.github.io/ble_scanner/ 

(2020/1/19 修正)
・BLEスキャンロストの制御を追加しました。5秒間、RSSIの更新がなければ、ロストとみなすようにしました。
・グラフ上の表示しない点は、値としてNaNを指定すればよいようです。

(2020/1/20 修正)
・貼り付けている画像をモーションGIFに変えました。(コメントありがとうございます！）

(2020/1/31 追加)
・obniz-nobleとobniz.js(m5stickc.js)の同時利用はサポートしていません。が。
・Forumにてアドバイスいただき、local_connect機能を無効にした接続を試してみています。(ただし、サポート対象外です）

#使うツール

・obniz-noble
　https://github.com/obniz/obniz-noble 
　今回の主役です。ESP32のObnizOS搭載デバイスをBLEセントラルにできます。

・Chart.js
　https://www.chartjs.org/ 
　Javascriptでグラフ表示するためのライブラリです。

・chartjs-plugin-colorschemes
　https://nagix.github.io/chartjs-plugin-colorschemes/ 
　グラフのLineの色を適当に選んでくれるプラグインです。

・Bootstrap(v3.4.1)
　https://getbootstrap.com/docs/3.4/ 
　超有名なCSS等を使ったWebフレームワークです。

・Vue(v2.x)
　https://jp.vuejs.org/index.html 
　Javascriptフレームワークです。データの双方向バインディングなどが特徴です。

あとは、obnizまたは最新のobnizOSが書き込まれたESP32が手元にある前提です。私はM5StickCを使いました。

# Javascriptソースコード

Javascriptのソースコードを載せちゃいます。

```js:start.js
'use strict';

//var vConsole = new VConsole();

var noble;

var timer = null;
var devices = [];
const NUM_OF_DATA = 50;
const UPDATE_INTERVAL = 1000;
const LOST_INTERVAL = 5000;
const COOKIE_EXPIRE = 365;

var vue_options = {
    el: "#top",
    data: {
        progress_title: '',

        obniz_id: '',
        device: null,
        num_of_data: NUM_OF_DATA,
        update_interval: UPDATE_INTERVAL,
        lost_interval: LOST_INTERVAL,
        obniz_connected: false,
        message: '',
    },
    computed: {
    },
    methods: {
        obniz_connect: function(){
            noble = obnizNoble(this.obniz_id);
            this.message = '接続試行中';

            noble.on('stateChange', (state) => {
                this.message = '';
                if (state === 'poweredOn') {
                    Cookies.set('obniz_id', this.obniz_id, { expires: COOKIE_EXPIRE });
                    this.obniz_connected = true;
                    noble.startScanning([], true);
    
                    this.interval_change();
                } else {
                    this.obniz_connected = false;
                    noble.stopScanning();
                }
            });
    
            noble.on('discover', (peripheral) => {
                var device = devices.find(item => item.peripheral.address == peripheral.address);
                if( device ){
//                    device.peripheral = peripheral;
                    device.peripheral.rssi = peripheral.rssi;
                    device.counter = 0;
                }else{
//                    var peri = peripheral;
                    var peri = {
                        address: peripheral.address,
                        addressType: peripheral.addressType,
                        connectable: peripheral.connectable,
                        advertisement: {
                            serviceUuids: peripheral.advertisement.serviceUuids,
                            manufacturerData: peripheral.advertisement.manufacturerData,
                            localName: peripheral.advertisement.localName,
                            txPowerLevel: peripheral.advertisement.txPowerLevel,
                        },
                        rssi: peripheral.rssi,
                    };

                    devices.push({
                        peripheral: peri,
                        display: "display",
                        datasets: [],
                        counter: 0,
                    });
                }
            });
        },
        interval_change: function(){
            if( timer != null ){
                clearTimeout(timer);
                timer = null;
            }
            timer = setInterval(() =>{
                this.update_graph();
            }, this.update_interval);
        },
        update_graph(){
            for( var i = 0 ; i < devices.length ; i++ ){
                if( devices[i].counter * this.update_interval < this.lost_interval ){
                    devices[i].datasets.unshift(devices[i].peripheral.rssi);
                    devices[i].counter++;
                }else{
                    devices[i].peripheral.rssi = NaN;
                    devices[i].datasets.unshift(NaN);
                }
            }

            var current_datasets = [];
            for( var i = 0 ; i < devices.length ; i++ ){
                current_datasets.push({
                    label: devices[i].peripheral.advertisement.localName || devices[i].peripheral.address,
                    data: [],
                    fill: false,
                    hidden: (devices[i].display != "display")
                });
            }

            if( current_datasets.length > 0 ){
                for( var i = 0 ; i < current_datasets.length ; i++ ){
                    for( var j = 0 ; j < this.num_of_data ; j++ ){
                        if( j > devices[i].datasets.length ){
                            current_datasets[i].data[this.num_of_data - 1 - j] = NaN;
                        }else{
                            current_datasets[i].data[this.num_of_data - 1 - j] = devices[i].datasets[j];
                        }
                    }
                }
                var labels = [];
                for( var i = 0 ; i < this.num_of_data ; i++ ){
                    labels.push(i - this.num_of_data + 1);
                }
                myChart.data.datasets = current_datasets;
                myChart.data.labels = labels;

                myChart.update();
            }
        }
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        this.obniz_id = Cookies.get('obniz_id');
    }
};
vue_add_methods(vue_options, methods_utils);
var vue = new Vue( vue_options );

var myChart = new Chart( $('#chart')[0].getContext('2d'), {
    type: 'line',
    data: {
        labels: [],
        datasets: []
    },
    options: {
        animation: false,
        scales: {
            yAxes: [{
                scaleLabel: {
                    display: true,
                    labelString: 'RSSI [dB]'
                }
            }]
        },
        legend: {
            position: "bottom",
            onClick: function(e, item){
                vue.device = devices[item.datasetIndex];
            }
        },
        plugins: {
            colorschemes: {
                scheme: 'brewer.Paired12'
            }
        }
    }
});
```

各関数の説明を付記しておきます。

・obniz_connect()
obnizと接続します。接続が完了すると、以下のコールバックが呼ばれます。

　`noble.on('stateChange', (state) => {`

そこで、BLEスキャンを開始します。
すると、スキャンに引っかかったBLEデバイスが以下のコールバックで通知されるようになります。

　`noble.on('discover', (peripheral) => {`

このコールバックの中で、BLEアドレスを見て、新しいデバイスであれば内部の配列に追加し、すでにある場合は、RSSI値を更新します。

・interval_change()
グラフの再描画の間隔を変更します。一番最初のグラフ再描画ルーチンの開始にも使います。

・ update_graph()
内部のデバイス用の配列に格納しておいた各BLEデバイスの最新RSSI値を取り出し、内部の履歴用の配列に追加します。この時先頭に追加します。

そして、履歴用の配列の先頭から指定された数分だけのデータを取り出し、グラフの再描画を行います。指定された数分に満たないデバイスは、NaNとして表示対象から外しています。
ちなみに、グラフのセットアップは、以下の部分です。

　`var myChart = new Chart(ctx, {`

以下の指定は、凡例を選択したときに、そのBLEデバイスの詳細を表示させるためのものです。(対象BLEデバイスのグラフの非表示/表示の切り替えも可能です）

```js
        legend: {
            position: "bottom",
            onClick: function(e, item){
                vue.device = devices[item.datasetIndex];
            }
        },
```

以下の部分は、今回お世話になったプラグインの指定です。

```js
        plugins: {
            colorschemes: {
                scheme: 'brewer.Paired12'
            }
        }
```

# HTMLソースコード

最後に、HTMLソースです。

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

  <title>BLEスキャン</title>

  <script src="js/methods_utils.js"></script>
  <script src="js/vue_utils.js"></script>

  <script src="dist/js/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

<!--
  <script src="https://unpkg.com/obniz/obniz.js"></script>
  <script src="https://unpkg.com/m5stickcjs/m5stickc.js"></script>
-->

  <script src="https://unpkg.com/obniz-noble/obniz-noble.js" crossorigin="anonymous"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.8.0/Chart.min.js"></script>
  <script src="https://unpkg.com/chartjs-plugin-colorschemes"></script>
</head>
<body>
    <div id="top" class="container">
        <h1>BLEスキャン</h1>
        <div class="form-inline">
            <label>obniz id</label>
            <input type="text" class="form-control" v-model="obniz_id" v-bind:readonly="obniz_connected">
            <button v-if="!obniz_connected" class="btn btn-default btn-sm" v-on:click="obniz_connect">接続</button>&nbsp;
            {{message}}
        </div>

        <div class="form-inline">
            <label>表示数</label>
            <select class="form-control input-sm" v-model.number="num_of_data">
                <option value="10">10</option>
                <option value="20">20</option>
                <option value="50">50</option>
                <option value="100">100</option>
            </select>&nbsp;
            <label>更新間隔</label>
            <select class="form-control input-sm" v-model.number="update_interval" v-on:change="interval_change">
                <option value="500">0.5s</option>
                <option value="1000">1s</option>
                <option value="5000">5s</option>
                <option value="10000">10s</option>
                <option value="60000">60s</option>
            </select>&nbsp;
            <label>ロスト間隔</label>
            <select class="form-control input-sm" v-model.number="lost_interval">
                <option value="500">0.5s</option>
                <option value="1000">1s</option>
                <option value="5000">5s</option>
                <option value="10000">10s</option>
                <option value="60000">60s</option>
            </select>
        </div>
        <br>
        <canvas id="chart"></canvas>
        <br>
        <div v-if="device" class="panel panel-default">
            <div class="panel-heading">
                {{device.peripheral.advertisement.localName || device.peripheral.address}}
            </div>
            <div class="panel-body">
                <div class="form-inline">
                    <label>グラフ表示</label> <select class="form-control input-sm" v-model="device.display">
                        <option value="display">表示</option>
                        <option value="hidden">非表示</option>
                    </select>
                </div>
                <label>localName</label> {{device.peripheral.advertisement.localName}}<br>
                <label>address</label> {{device.peripheral.address}}<br>
                <label>RSSI</label> {{device.peripheral.rssi}}<br>
                <label>addressType</label> {{device.peripheral.addressType}}<br>
                <label>connectable</label> {{device.peripheral.connectable}}<br>
                <label>serviceUuids</label> {{device.peripheral.advertisement.serviceUuids}}<br>
                <div v-if="device.peripheral.advertisement.manufacturerData">
                    <label>manufacturerData</label> {{device.peripheral.advertisement.manufacturerData.toString('hex')}}<br>
                </div>
                <div v-if="device.peripheral.advertisement.txPowerLevel">
                    <label>txPowerLevel</label> {{device.peripheral.advertisement.txPowerLevel}}<br>
                </div>
            </div>
        </div>
        <br>


        <div class="modal fade" id="progress">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h4 class="modal-title">{{progress_title}}</h4>
                    </div>
                    <div class="modal-body">
                        <center><progress max="100" /></center>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script src="js/start.js"></script>
</body>
```

Vue.jsやBootstrapを使い倒しています。
その他、細かなファイルがありますが、GitHubをご参照ください。

以上