M5Stack UIFlowのBLE UARTでChromeと接続し環境センサー値をGoogle Chartsで表示

#概要

以前「[M5GO(M5Stack)とChromeをBLEで接続し環境センサー値をGoogle Chartsで表示](https://qiita.com/inasawa/items/3280586b95b03b88aab2)」で Arduino で作成したプログラムで Chrome との BLE による接続を試しましたが、M5Stack Fire/Core2 では UIFlow (最新版は 1.7.1) で BLE が使えるようになったので、今回は UIFlow の Blockly を使って Chrome との BLE による接続を試しました。

- M5Stack で付属の環境センサー（DHT12、BMP280）から値を取得
- M5Stack と Chrome を LE で接続
- M5Stack から BLE の Notify にて環境センサー値を Chrome へ通知
- Chrome側で取得した環境センサー値を Google Charts で表示

おまけとして Blockly で使用している ble_uart モジュールではなく、より基本的な bluetooth モジュールを使って同様の機能を実装してみました。

#環境

- M5Stack Fire (UIFlow 1.7.1)
- iMac (macOS Catalina)
- Google Chrome (87.0.4280.141)

#実行結果

###実行手順
1. M5Stack Fire側のプログラムを実行
1. HTML を直接 Chrome で開く
1. BLE での接続用ボタン「BLE接続」をクリック（ユーザ操作無しでの接続は不可）
1. 「BLE Env. Sensor」とペア設定
1. M5Stack から BLE経由 で送られてくる気温・湿度・気圧値が Google Charts で表示される

###M5Stack の表示
センサーが不調のようで湿度が低すぎ。実際には40%程度。
「気温」「気圧」を「氣温」「氣压」としているのは lcd.FONT_UNICODE が中国語用のフォントで「気」や「圧」等、一部の日本語の漢字が表示できないためです。（ちなみに M5Stack Core2 ではこれらの日本語の漢字も表示可能です）
![IMG_5907.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133064/5b84727f-855f-e2fb-576b-f0719b50cb46.jpeg)

###Chrome の表示
![スクリーンショット 2021-01-10 21.58.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133064/850878c2-1c90-cb28-6d90-bd10be324622.png)

#プログラム
###UIFlow の Blockly で作成したプログラム

![スクリーンショット 2021-01-10 21.48.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133064/f241ba12-ba69-a7f7-dac8-d38a844e5506.png)
![スクリーンショット 2021-01-10 21.49.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133064/e6fcda68-63dd-d4e8-c7d7-9c5e67cdea14.png)

###Chrome側プログラム
以前のものとの主な変更点は Service/Characteristic の UUID を UARTのものに変えた程度。

```html:
<html>
    <head>
        <title>気温・湿度・気圧計 - Google Charts</title>
        <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
        <script type="text/javascript">
            google.charts.load('current', {'packages':['gauge', 'corechart']});
            google.charts.setOnLoadCallback(chartSetup);

            var optionGaugeT = {
                    width: 250, height: 250,
                    min: -10, max: 50,
                    majorTicks: ["-10", "0", "10", "20", "30", "40", "50"],
                    minorTicks: 5
                };
            var optionGaugeH = {
                    width: 250, height: 250,
                    min: 0, max: 100,
                    majorTicks: ["0", "20", "40", "60", "80", "100"],
                    minorTicks: 4
                };
            var optionGaugeP = {
                    width: 250, height: 250,
                    min: 940, max: 1060,
                    majorTicks: ["940", "960", "980", "1000", "1020", "1040", "1060"],
                    minorTicks: 4
                };
            var optionLineTH = {
                    title: '気温＆湿度の変化',
                    width: 1000,
                    height: 300,
                    colors: ['#DC3912', '#3366CC'],
                    // Gives each series an axis that matches the vAxes number below.
                    series: {
                        0: {targetAxisIndex: 0},
                        1: {targetAxisIndex: 1}
                    },
                    vAxes: {
                      // Adds titles to each axis.
                        0: {title: '気温',
                            format: '#.#℃'},
                        1: {title: '湿度',
                            format: '#.#%'}
                    },
                    hAxis: {
                        format: 'HH:mm:ss'
                    },
                    vAxis: {
                        titleTextStyle: {italic: false}
                    }
                };

            var optionLineP = {
                    title: '気圧の変化',
                    width: 1000,
                    height: 300,
                    // Gives each series an axis that matches the vAxes number below.
                    series: {
                        0: {targetAxisIndex: 0}
                    },
                    vAxes: {
                      // Adds titles to each axis.
                        0: {title: '気圧',
                            format: '#.#hPa'}
                    },
                    hAxis: {
                        format: 'HH:mm:ss'
                    },
                    vAxis: {
                        titleTextStyle: {italic: false}
                    }
                };

            var dataGaugeT;
            var dataGaugeH;
            var dataGaugeP;
            var dataLineTH;
            var dataLineP;

            var chartGaugeT;
            var chartGaugeH;
            var chartGaugeP;
            var chartLineTH;
            var chartLineP;

            var formatterT;
            var formatterH;
            var formatterP;

            function chartSetup() {
                dataGaugeT = google.visualization.arrayToDataTable([
                        ['Label', 'Value'],
                        ['気温', 0]
                    ]);
                dataGaugeH = google.visualization.arrayToDataTable([
                        ['Label', 'Value'],
                        ['湿度', 0]
                    ]);
                dataGaugeP = google.visualization.arrayToDataTable([
                        ['Label', 'Value'],
                        ['気圧', 0]
                  ]);

                dataLineTH = new google.visualization.DataTable();
                dataLineTH.addColumn('datetime', '日時');
                dataLineTH.addColumn('number',   '気温');
                dataLineTH.addColumn('number',   '湿度');

                dataLineP = new google.visualization.DataTable();
                dataLineP.addColumn('datetime', '日時');
                dataLineP.addColumn('number',   '気圧');

                chartGaugeT = new google.visualization.Gauge(document.getElementById('gauge_chart_temperature'));
                chartGaugeH = new google.visualization.Gauge(document.getElementById('gauge_chart_humidity'));
                chartGaugeP = new google.visualization.Gauge(document.getElementById('gauge_chart_pressure'));
                chartLineTH = new google.visualization.LineChart(document.getElementById('line_chart_temp_hum'));
                chartLineP  = new google.visualization.LineChart(document.getElementById('line_chart_pressure'));

                formatterT = new google.visualization.NumberFormat({suffix:' ℃', fractionDigits:1});
                formatterH = new google.visualization.NumberFormat({suffix:' %', fractionDigits:1});
                formatterP = new google.visualization.NumberFormat({suffix:' hPa', fractionDigits:1});

                updateCharts(undefined, undefined, undefined);
            }

            function updateCharts(t, h, p) {
                serverTime = new Date();
                //document.getElementById('datetime').innerHTML=serverTime.toLocaleString();
                document.getElementById('datetime').innerHTML =
                                    serverTime.getFullYear() + '/'
                            +  ('0' + (serverTime.getMonth() + 1)).slice(-2) + '/'
                            +  ('0' +  serverTime.getDate()).slice(-2) + ' '
                            +  ('0' +  serverTime.getHours()).slice(-2) + ':'
                            +  ('0' +  serverTime.getMinutes()).slice(-2) + ':'
                            +  ('0' +  serverTime.getSeconds()).slice(-2);

                if (t === undefined) {
                    optionLineTH['title'] = '気温＆湿度の変化';
                    optionLineP['title']  = '気圧の変化';
                } else {
                    dataGaugeT.setValue(0, 1, t);
                    dataGaugeH.setValue(0, 1, h);
                    dataGaugeP.setValue(0, 1, p);

                    dataLineTH.addRow([serverTime, t, h / 100])
                    if (dataLineTH.getNumberOfRows() > 600)
                        dataLineTH.removeRow(0)

                    dataLineP.addRow([serverTime, p])
                    if (dataLineP.getNumberOfRows() > 600)
                        dataLineP.removeRow(0)

                    optionLineTH['title'] = '気温＆湿度の変化  '
                                        + dataLineTH.getValue(0, 0).toLocaleString()
                                        + ' - '
                                        + dataLineTH.getValue(dataLineTH.getNumberOfRows()-1, 0).toLocaleString();

                    optionLineP['title'] = '気圧の変化  '
                                        + dataLineP.getValue(0, 0).toLocaleString()
                                        + ' - '
                                        + dataLineP.getValue(dataLineP.getNumberOfRows()-1, 0).toLocaleString();
                }
                formatterT.format(dataGaugeT, 1);
                formatterH.format(dataGaugeH, 1);
                formatterP.format(dataGaugeP, 1);

                chartGaugeT.draw(dataGaugeT, optionGaugeT);
                chartGaugeH.draw(dataGaugeH, optionGaugeH);
                chartGaugeP.draw(dataGaugeP, optionGaugeP);
                chartLineTH.draw(dataLineTH, optionLineTH);
                chartLineP.draw(dataLineP, optionLineP);
            }

            var bluetoothDevice;
            var characteristic;
            var service;

            var SERVICE_UUID        = '6e400001-b5a3-f393-e0a9-e50e24dcca9e';
            var CHARACTERISTIC_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e';

            function toggle_connection() {
                if (!bluetoothDevice || !bluetoothDevice.gatt.connected)
                    connect();
                else
                    disconnect();
            }

            //BEL接続処理
            function connect() {
                let options = {};

                options.filters = [
                    {services: [SERVICE_UUID]},
                    {name: "BLE Env. Sensor"}
                ];

                navigator.bluetooth.requestDevice(options)
                .then(device => {
                    bluetoothDevice = device;
                    console.log("device", device);
                    return device.gatt.connect();
                })
                .then(server =>{
                    console.log("server", server)
                    return server.getPrimaryService(SERVICE_UUID);
                })
                .then(serv => {
                    service = serv;
                    console.log("service", service)
                    return service.getCharacteristic(CHARACTERISTIC_UUID)
                })
                .then(chara => {
                    document.getElementById('toggle_connection').innerHTML = "BLE 切断";
                    console.log("characteristic", chara)
                    characteristic = chara;
                    characteristic.addEventListener('characteristicvaluechanged', onRecvSensorData);
                            characteristic.startNotifications();
                })
                .catch(error => {
                    console.log(error);
                });
            }

            //BEL切断処理
            function disconnect() {
                document.getElementById('toggle_connection').innerHTML = "BLE 接続";
                if (!bluetoothDevice || !bluetoothDevice.gatt.connected) return;
                bluetoothDevice.gatt.disconnect();
            }

            function onRecvSensorData(event) {
                let characteristic = event.target;
                let value = characteristic.value;
                let decoder = new TextDecoder('utf-8');

                ble_env = decoder.decode(value).split(',')
                // ble_env[0] Temperature from DHT12
                // ble_env[1] Humidity    from DHT12
                // ble_env[2] Pressure    from BMP280
                console.log(ble_env[0] + ', ' + ble_env[1] + ', ' + ble_env[2])

                updateCharts(Number(ble_env[0]), Number(ble_env[1]), Number(ble_env[2]));
            }

       </script>
    </head>
    <body>
        <div style="width: 1000px; margin: 0 auto;">
            <div style="text-align: center;">
                <button id="toggle_connection" style="font-size: 120%; font-family: sans-serif;" onClick="toggle_connection()">BLE 接続</button>
            </div>
            <div id="datetime" style="text-align: center; font-size: 300%; font-family: sans-serif;"></div>
            <div style=" display:flex;justify-content: center;">
                <div id="gauge_chart_temperature"></div>
                <div id="gauge_chart_humidity"></div>
                <div id="gauge_chart_pressure"></div>
            </div>
            <div id="line_chart_temp_hum"></div>
            <div id="line_chart_pressure"></div>
        </div>
    </body>
</html>
```

###UIFlow で生成された MicroPython プログラム
```python:
from m5stack import *
from m5ui import *
from uiflow import *
from ble import ble_uart
import time
import unit

setScreenColor(0x222222)
env0 = unit.get(unit.ENV, unit.PORTA)

t = None
h = None
p = None

label0 = M5TextBox(160, 50, "Text", lcd.FONT_DejaVu24, 0xFFFFFF, rotate=0)
label1 = M5TextBox(160, 100, "Text", lcd.FONT_DejaVu24, 0xFFFFFF, rotate=0)
label2 = M5TextBox(160, 150, "Text", lcd.FONT_DejaVu24, 0xFFFFFF, rotate=0)
label3 = M5TextBox(50, 50, "氣温", lcd.FONT_UNICODE, 0xFFFFFF, rotate=0)
label4 = M5TextBox(50, 100, "湿度", lcd.FONT_UNICODE, 0xFFFFFF, rotate=0)
label5 = M5TextBox(50, 150, "氣压", lcd.FONT_UNICODE, 0xFFFFFF, rotate=0)

uart_ble = ble_uart.init('BLE Env. Sensor')
while True:
  t = env0.temperature
  h = env0.humidity
  p = env0.pressure
  label0.setText(str(t))
  label1.setText(str(h))
  label2.setText(str(p))
  uart_ble.write(','.join([str(t), str(h), str(p)]))
  wait(1)
```

###bluetooth モジュールを使って実装した MicroPython プログラム

以下は同様の機能を公式の micropython のサンプルプログラム [ble_uart_peripheral.py](https://github.com/micropython/micropython/blob/master/examples/bluetooth/ble_uart_peripheral.py) を元に修正したもので、
```from ble_advertising``` を ```from ble.ble_advertising``` とすることでイベント発生時の処理はそのまま動かすことができ、接続時・切断時の処理を追加したりできます。

```python:
from m5stack import *
from m5ui import *
from uiflow import *
import unit

import bluetooth
#from ble_advertising import advertising_payload
#UIFlow では ble_advertising を ble.ble_advertising に修正する必要あり
from ble.ble_advertising import advertising_payload

from micropython import const

_IRQ_CENTRAL_CONNECT = const(1)
_IRQ_CENTRAL_DISCONNECT = const(2)
_IRQ_GATTS_WRITE = const(3)

_FLAG_WRITE = const(0x0008)
_FLAG_NOTIFY = const(0x0010)

_UART_UUID = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
_UART_TX = (
    bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"),
    _FLAG_NOTIFY,
)
_UART_RX = (
    bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"),
    _FLAG_WRITE,
)
_UART_SERVICE = (
    _UART_UUID,
    (_UART_TX, _UART_RX),
)

# org.bluetooth.characteristic.gap.appearance.xml
_ADV_APPEARANCE_GENERIC_COMPUTER = const(128)


class BLEUART:
    def __init__(self, ble, name="mpy-uart", rxbuf=100):
        self._ble = ble
        self._ble.active(True)
        self._ble.irq(self._irq)
        ((self._tx_handle, self._rx_handle),) = self._ble.gatts_register_services((_UART_SERVICE,))
        # Increase the size of the rx buffer and enable append mode.
        self._ble.gatts_set_buffer(self._rx_handle, rxbuf, True)
        self._connections = set()
        self._rx_buffer = bytearray()
        self._handler = None
        # Optionally add services=[_UART_UUID], but this is likely to make the payload too large.
        self._payload = advertising_payload(name=name, appearance=_ADV_APPEARANCE_GENERIC_COMPUTER)
        self._advertise()

    def irq(self, handler):
        self._handler = handler

    def _irq(self, event, data):
        # Track connections so we can send notifications.
        if event == _IRQ_CENTRAL_CONNECT:
            label4.setText("接續")
            conn_handle, _, _ = data
            self._connections.add(conn_handle)
        elif event == _IRQ_CENTRAL_DISCONNECT:
            conn_handle, _, _ = data
            if conn_handle in self._connections:
                label4.setText("切断")
                self._connections.remove(conn_handle)
            # Start advertising again to allow a new connection.
            self._advertise()
        elif event == _IRQ_GATTS_WRITE:
            conn_handle, value_handle = data
            if conn_handle in self._connections and value_handle == self._rx_handle:
                self._rx_buffer += self._ble.gatts_read(self._rx_handle)
                if self._handler:
                    self._handler()

    def any(self):
        return len(self._rx_buffer)

    def read(self, sz=None):
        if not sz:
            sz = len(self._rx_buffer)
        result = self._rx_buffer[0:sz]
        self._rx_buffer = self._rx_buffer[sz:]
        return result

    def write(self, data):
        for conn_handle in self._connections:
            self._ble.gatts_notify(conn_handle, self._tx_handle, data)

    def close(self):
        for conn_handle in self._connections:
            self._ble.gap_disconnect(conn_handle)
        self._connections.clear()

    def _advertise(self, interval_us=500000):
        self._ble.gap_advertise(interval_us, adv_data=self._payload)


def demo():
    env0 = unit.get(unit.ENV, unit.PORTA)

    ble = bluetooth.BLE()
    uart = BLEUART(ble, 'BLE Env. Sensor')

    def on_rx():
        print("rx: ", uart.read().decode().strip())

    uart.irq(handler=on_rx)

    while True:
      t = env0.temperature
      h = env0.humidity
      p = env0.pressure
      label5.setText(str(t))
      label6.setText(str(h))
      label7.setText(str(p))
      uart.write(','.join([str(t), str(h), str(p)]))
      wait(1)

    #uart.close()

setScreenColor(0x222222)
label0 = M5TextBox( 50, 40*1, "状態", lcd.FONT_UNICODE, 0xFFFFFF, rotate=0)
label1 = M5TextBox( 50, 40*2, "氣温", lcd.FONT_UNICODE, 0xFFFFFF, rotate=0)
label2 = M5TextBox( 50, 40*3, "湿度", lcd.FONT_UNICODE, 0xFFFFFF, rotate=0)
label3 = M5TextBox( 50, 40*4, "氣压", lcd.FONT_UNICODE, 0xFFFFFF, rotate=0)
label4 = M5TextBox(150, 40*1, "切断", lcd.FONT_UNICODE, 0xFFFFFF, rotate=0)
label5 = M5TextBox(150, 40*2, "", lcd.FONT_DejaVu24, 0xFFFFFF, rotate=0)
label6 = M5TextBox(150, 40*3, "", lcd.FONT_DejaVu24, 0xFFFFFF, rotate=0)
label7 = M5TextBox(150, 40*4, "", lcd.FONT_DejaVu24, 0xFFFFFF, rotate=0)
demo()
#
#UIFlow から動かした場合 __name__ は "flow.m5cloud" になる
#if __name__ == "__main__":
#    demo()
```

以下のように UIFlow の ble_uart モジュールの中を覗いてみると、中身は ble_uart_peripheral.py と同じような気がします。

```
>>> from ble import ble_uart
>>> help(ble_uart)
object <module 'ble.ble_uart' from 'ble/ble_uart.py'> is of type module
  const -- <function>
  BLEUART -- <class 'BLEUART'>
  advertising_payload -- <function advertising_payload at 0x3f81bae0>
  _UART_UUID -- UUID128('6e400001-b5a3-f393-e0a9-e50e24dcca9e')
  __file__ -- ble/ble_uart.py
  init -- <function init at 0x3f81bde0>
  __name__ -- ble.ble_uart
  bluetooth -- <module 'ubluetooth'>
  _UART_SERVICE -- (UUID128('6e400001-b5a3-f393-e0a9-e50e24dcca9e'), ((UUID128('6e400003-b5a3-f393-e0a9-e50e24dcca9e'), 16), (UUID128('6e400002-b5a3-f393-e0a9-e50e24dcca9e'), 8)))
  _UART_RX -- (UUID128('6e400002-b5a3-f393-e0a9-e50e24dcca9e'), 8)
  _ble_uart -- None
  _UART_TX -- (UUID128('6e400003-b5a3-f393-e0a9-e50e24dcca9e'), 16)
>>>
>>> help(ble_uart.BLEUART)
object <class 'BLEUART'> is of type type
  any -- <function any at 0x3f81be50>
  __qualname__ -- BLEUART
  irq -- <function irq at 0x3f81bd60>
  __module__ -- ble.ble_uart
  read -- <function read at 0x3f81be80>
  rename -- <function rename at 0x3f81c0b0>
  close -- <function close at 0x3f81c070>
  write -- <function write at 0x3f81c060>
  _irq -- <function _irq at 0x3f81be70>
  _advertise -- <function _advertise at 0x3f81c090>
  __init__ -- <function __init__ at 0x3f81be30>
>>> 
```