M5GO(M5Stack)とMac(Python)をBLEで接続し環境センサー値をGoogle Chartsで表示

#概要

- M5GOで付属の環境センサー（DHT12、BMP280）から値を取得
- M5GOとMacの接続はBluetooth LEを使用しNotifyで通知
- Mac側はPythonからCoreBluetooth frameworkを使用して接続
- Pythonからpywebviewを使用しGoogle Chartsにより取得値を表示

以前作成した「[M5GO(M5Stack)とChromeをBLEで接続し環境センサー値をGoogle Chartsで表示](https://qiita.com/inasawa/items/3280586b95b03b88aab2)」を
Mac上のPythonでBLEを使用するためのお試しプログラムとして作成しました。
表示部分はChrome版と同様のものでpywebviewを使って表示しています。

##環境

- M5GO (Arduino IDE 1.8.7)
- iMac (macOS Mojave)
- Python 3.6.4
- pyobjc-framework-CoreBluetooth 5.2
- pywebview 2.4

#実行結果

<img width="1068" alt="M5GO_Env_BLE_Notify.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133064/b1ba4413-33ee-365f-a8e7-d008784af4ab.png">
（センサーのDHT12が不調のようで湿度の値が90%と高くなっていますが、実際は55％程度です）

#M5GO側プログラム

「[M5GO(M5Stack)とChromeをBLEで接続し環境センサー値をGoogle Chartsで表示](https://qiita.com/inasawa/items/3280586b95b03b88aab2)」で使用しているものと同じ

#Mac側プログラム
```python:M5GO_Env_BLE_Notify.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import webview
import signal
from queue import Queue
from threading import Thread
from Foundation import CBCentralManager, CBUUID
from PyObjCTools import AppHelper

M5GO_NAME = 'M5GO Env. Sensor'
M5GO_SERVICE = CBUUID.UUIDWithString_(u'c44205a6-c87c-11e8-a8d5-f2801f1b9fd1')
M5GO_CHARACTERISTIC = CBUUID.UUIDWithString_(u'c442090c-c87c-11e8-a8d5-f2801f1b9fd1')

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.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);
            }
       </script>
    </head>
    <body>
        <div style="width: 1000px; margin: 0 auto;">
            <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>
"""

class CBCentralManagerDelegate(object):
    def __init__(self):
        self.peripheral = None

    def centralManagerDidUpdateState_(self, manager):
        self.manager = manager
        manager.scanForPeripheralsWithServices_options_(None, None)

    def centralManager_didDiscoverPeripheral_advertisementData_RSSI_(self, manager, peripheral, data, rssi):
        self.peripheral = peripheral
        if peripheral.name() is not None:
            print('DeviceName:', peripheral.name())
            if peripheral.name() == M5GO_NAME:
                manager.connectPeripheral_options_(peripheral, None)
                manager.stopScan()

    def centralManager_didConnectPeripheral_(self, manager, peripheral):
        print('Connected:', peripheral.name())
        peripheral.setDelegate_(self)
        peripheral.discoverServices_([M5GO_SERVICE])
        
    def centralManager_didDisconnectPeripheral_error_(self, manager, peripheral, error):
        print('Disconnected:', peripheral.name())
        self.peripheral = None
        manager.scanForPeripheralsWithServices_options_(None, None)
        
    def peripheral_didDiscoverServices_(self, peripheral, error):
        for service in peripheral.services():
            if service.UUID() == M5GO_SERVICE:
                peripheral.discoverCharacteristics_forService_([M5GO_CHARACTERISTIC], service)
                break

    def peripheral_didDiscoverCharacteristicsForService_error_(self, peripheral, service, error):
        for characteristic in service.characteristics():
            if characteristic.UUID() == M5GO_CHARACTERISTIC:
                peripheral.setNotifyValue_forCharacteristic_(True, characteristic)
                break
    
    def peripheral_didUpdateValueForCharacteristic_error_(self, peripheral, characteristic, error):
        if len(characteristic.value()) > 0:
            value = characteristic.value().bytes().tobytes().decode('utf8')
            print(value)
            # ここで直接 webview.evaluate_js() を呼び出すと止まってしまうので、Queue経由で連携
            env_data_queue.put(value)

def load_html():
    webview.load_html(HTML)
    while True:
        # BLE経由で通知されたデータをQueue経由で取得
        env_data = env_data_queue.get()
        env = env_data.split(',')
        webview.evaluate_js('updateCharts({},{},{});'.format(env[0],env[1],env[2]))

def signal_handler(signal, frame):
    AppHelper.stopEventLoop()

if __name__ == '__main__':
    env_data_queue = Queue()
    t = Thread(target=load_html)
    t.start()
    central_manager = CBCentralManager.alloc()
    central_manager.initWithDelegate_queue_options_(CBCentralManagerDelegate(), None, None)
    
    # Ctrl+C で停止
    signal.signal(signal.SIGINT, signal_handler)

    webview.create_window('気温・湿度・気圧計', width=1000, height=1000, debug=True)
    # Window が閉じられたら停止
    AppHelper.stopEventLoop()
```