BME280 > BLE > MQTT > Flutterで見る

外に設置したBME280をBLE通信にて家の中に設置した使わないスマホにてFlutterで受ける。Flutterからは公衆ブローカーにMQTTで投げてかつThingSpeakにも投げる。手持ちのFlutterから家の中の温度、湿度、気圧を見る。ThingSpeakも見る。ランニングコストは0円。この仕組みを利用したらBLE、MQTTで気象計で無くとも低コストでIOTシステムを構築できる。
![bme280.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806156/05fcff01-c2c2-838e-f1d6-eab3d37e8ac4.png)


# PicoW

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806156/8d80c991-ab08-061d-288c-d1704333d00b.jpeg">



micropythonでプログラミングする。
ラズパイPicoとBME280センサーを使用して温度、湿度、気圧のデータを測定し、BLE GATT（Generic Attribute Profile）を通じてこれらのデータを送信する場合、設計には以下の2つの方法があります。
1. 個別のサービスとして分ける方法:
	- 温度、湿度、気圧の各データを個別のサービスとして定義し、それぞれに特性値を持たせる。
2. 1つのサービスにまとめる方法:
	- すべてのデータを1つの「気象サービス」として定義し、その中に温度、湿度、気圧の3つの特性値を持たせる。
	
1つの「気象サービス」にまとめ、その中に温度、湿度、気圧の特性値を持たせる方法を推奨します。これは、管理の容易さとデータ取得の効率性のバランスが良いためです。

1. 10秒毎くらいに定期的にNotify通知する。
2. IOTサーバーに24時間分の30分毎の温度、湿度、気圧データを蓄積する。後々Flutterでグラフ表示させるため。IOTサーバーにはThingspeakを採用する。

<details><summary>picow.py</summary>


```python

import sys
import machine
from machine import I2C, Pin
import time
import uasyncio as asyncio
import aioble
import bluetooth
import random
import struct
import urequests
import network
import utime
import ntptime

# ThingSpeakの設定
THINGSPEAK_API_KEY = 'XXXXX'
THINGSPEAK_URL = "https://api.thingspeak.com/update"

# Wi-Fi設定
SSID = "XXXXXXXX"  
PASSWORD = "XXXXXXX"

# UUID定義
_LED_SERVICE_UUID = bluetooth.UUID('A8DFA1D0-C369-4466-B54F-E75C6DDDE611')
_LED_CHAR_UUID = bluetooth.UUID('A8DFA1D1-C369-4466-B54F-E75C6DDDE611')
_TEMP_SERVICE_UUID = bluetooth.UUID('10F76FC0-D12E-4BF3-9837-9831CF294397')
_TEMP_CHAR_UUID = bluetooth.UUID('10F76FC1-D12E-4BF3-9837-9831CF294397')
_HUMIDITY_CHAR_UUID = bluetooth.UUID('10F76FC2-D12E-4BF3-9837-9831CF294397')
_PRESSURE_CHAR_UUID = bluetooth.UUID('10F76FC3-D12E-4BF3-9837-9831CF294397')

_ADV_INTERVAL_MS = 250_000  # Advertiseの送信頻度(ms)

# LEDとI2Cの設定
led = machine.Pin('LED', machine.Pin.OUT)
i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=100000)
address = 0x76

# Wi-Fi接続関数
def connect_wifi(ssid, password):
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(ssid, password)
    while not wlan.isconnected():
        time.sleep(1)
    print('Wi-Fi connected:', wlan.ifconfig())


def sync_time():
    # try:
    #     ntptime.settime()
    #     print("Time synchronized")
    #     # 現在のUTC時刻を取得し、日本標準時に変換
    #     t = time.time() + 9 * 3600  # 9時間を追加
    #     tm = time.localtime(t)
    #     machine.RTC().datetime((tm[0], tm[1], tm[2], tm[6], tm[3], tm[4], tm[5], 0))
    # except Exception as e:
    #     print("Failed to synchronize time:", e)

    try:
        ntptime.settime()
        print("Time synchronized")
        # 現在のUTC時刻を取得し、日本標準時に変換
        t = time.time() + 9 * 3600  # 9時間を追加
        return time.localtime(t)
    except Exception as e:
        print("Failed to synchronize time:", e)
        return None


# BME280の初期設定
def init_bme280():
    i2c.writeto_mem(address, 0xF2, bytes([0x01]))  # 湿度オーバーサンプリング設定
    i2c.writeto_mem(address, 0xF4, bytes([0x27]))  # 温度と気圧のオーバーサンプリング設定およびモード設定
    i2c.writeto_mem(address, 0xF5, bytes([0xA0]))  # 設定レジスタ（スタンバイ時間とフィルターの設定）

# 補正データの読み込み
def read_compensate():
    global digT, digP, digH  # グローバル変数を使用
    digT = []
    digP = []
    digH = []

    # 温度補正データの読み込み
    dat_t = i2c.readfrom_mem(address, 0x88, 6)
    digT = [
        (dat_t[1] << 8) | dat_t[0],
        (dat_t[3] << 8) | dat_t[2],
        (dat_t[5] << 8) | dat_t[4]
    ]
    for i in range(1, 2):
        if digT[i] >= 32768:
            digT[i] -= 65536

    # 気圧補正データの読み込み
    dat_p = i2c.readfrom_mem(address, 0x8E, 18)
    digP = [
        (dat_p[1] << 8) | dat_p[0],
        (dat_p[3] << 8) | dat_p[2],
        (dat_p[5] << 8) | dat_p[4],
        (dat_p[7] << 8) | dat_p[6],
        (dat_p[9] << 8) | dat_p[8],
        (dat_p[11] << 8) | dat_p[10],
        (dat_p[13] << 8) | dat_p[12],
        (dat_p[15] << 8) | dat_p[14],
        (dat_p[17] << 8) | dat_p[16]
    ]
    for i in range(1, 8):
        if digP[i] >= 32768:
            digP[i] -= 65536

    # 湿度補正データの読み込み
    digH = [i2c.readfrom_mem(address, 0xA1, 1)[0]]
    dat_h = i2c.readfrom_mem(address, 0xE1, 7)
    digH.extend([
        (dat_h[1] << 8) | dat_h[0],
        dat_h[2],
        (dat_h[3] << 4) | (0x0F & dat_h[4]),
        (dat_h[5] << 4) | ((dat_h[4] >> 4) & 0x0F),
        dat_h[6]
    ])
    if digH[1] >= 32768:
        digH[1] -= 65536
    for i in range(3, 5):
        if digH[i] >= 32768:
            digH[i] -= 65536
    if digH[5] >= 128:
        digH[5] -= 256

# 測定データの読み込みと補正
def read_data():
    dat = i2c.readfrom_mem(address, 0xF7, 8)
    dat_p = (dat[0] << 12) | (dat[1] << 4) | (dat[2] >> 4)
    dat_t = (dat[3] << 12) | (dat[4] << 4) | (dat[5] >> 4)
    dat_h = (dat[6] << 8) | dat[7]

    tmp = bme280_compensate_t(dat_t)
    prs = bme280_compensate_p(dat_p)
    hum = bme280_compensate_h(dat_h)

    # print('t = {:.2f} C'.format(tmp))
    # print('p = {:.2f} hPa'.format(prs))
    # print('h = {:.2f} %'.format(hum))

    return tmp, prs, hum

# 温度データの補正
def bme280_compensate_t(adc_T):
    global t_fine
    var1 = (adc_T / 16384.0 - digT[0] / 1024.0) * digT[1]
    var2 = ((adc_T / 131072.0 - digT[0] / 8192.0) * (adc_T / 131072.0 - digT[0] / 8192.0)) * digT[2]
    t_fine = var1 + var2
    T = t_fine / 5120.0
    return T

# 気圧データの補正
def bme280_compensate_p(adc_P):
    global t_fine
    var1 = t_fine / 2.0 - 64000.0
    var2 = var1 * var1 * digP[5] / 32768.0
    var2 = var2 + var1 * digP[4] * 2.0
    var2 = var2 / 4.0 + digP[3] * 65536.0
    var1 = (digP[2] * var1 * var1 / 524288.0 + digP[1] * var1) / 524288.0
    var1 = (1.0 + var1 / 32768.0) * digP[0]
    if var1 == 0:
        return 0
    P = 1048576.0 - adc_P
    P = (P - var2 / 4096.0) * 6250.0 / var1
    var1 = digP[8] * P * P / 2147483648.0
    var2 = P * digP[7] / 32768.0
    P = P + (var1 + var2 + digP[6]) / 16.0
    return P / 100

# 湿度データの補正
def bme280_compensate_h(adc_H):
    global t_fine
    var_H = t_fine - 76800.0
    var_H = (adc_H - (digH[3] * 64.0 + digH[4] / 16384.0 * var_H)) * (digH[1] / 65536.0 * (1.0 + digH[5] / 67108864.0 * var_H * (1.0 + digH[2] / 67108864.0 * var_H)))
    var_H = var_H * (1.0 - digH[0] * var_H / 524288.0)
    if var_H > 100.0:
        var_H = 100.0
    elif var_H < 0.0:
        var_H = 0.0
    return var_H

# BME280の初期化
init_bme280()
read_compensate()

# GATTサービスとキャラクタリスティックの登録
led_service = aioble.Service(_LED_SERVICE_UUID)
led_characteristic = aioble.Characteristic(led_service, _LED_CHAR_UUID, read=True, write=True)
temp_service = aioble.Service(_TEMP_SERVICE_UUID)
temp_characteristic = aioble.Characteristic(temp_service, _TEMP_CHAR_UUID, read=True, notify=True)
humidity_characteristic = aioble.Characteristic(temp_service, _HUMIDITY_CHAR_UUID, read=True, notify=True)
pressure_characteristic = aioble.Characteristic(temp_service, _PRESSURE_CHAR_UUID, read=True, notify=True)
aioble.register_services(led_service, temp_service)

# セントラル側から送られてきた数字でLED点灯ON.OFF
def led_cmd(cmd):
    if cmd == 1:
        led.on()
    if cmd == 0:
        led.off()

# LEDの状態を更新する関数
async def led_task():
    while True:
        await led_characteristic.written()
        msg = led_characteristic.read()
        led_characteristic.write(msg)
        led_cmd(msg[0])
        await asyncio.sleep_ms(100)

# 温度、湿度、気圧データを更新し、通知を送信する関数
async def sensor_task():
    while True:
        # try:
            tmp, prs, hum = read_data()
            temp_str = "{0:.2f}".format(tmp)
            humidity_str = "{0:.2f}".format(hum)
            pressure_str = "{0:.2f}".format(prs)

            # print("Temperature:", temp_str)
            # print("Humidity:", humidity_str)
            # print("Pressure:", pressure_str)

            temp_characteristic.write(temp_str.encode('utf-8'), send_update=True)
            humidity_characteristic.write(humidity_str.encode('utf-8'), send_update=True)
            pressure_characteristic.write(pressure_str.encode('utf-8'), send_update=True)

            await asyncio.sleep(10)  # 10秒待つ
        # except Exception as e:
        #     print("Error in sensor_task:", e)



# 接続を待ち受ける関数
async def peripheral_task():
    while True:
        connection = await aioble.advertise(
            _ADV_INTERVAL_MS,
            name="picoLEDTempHumPress",
            services=[_LED_SERVICE_UUID],
        )
        await connection.disconnected(timeout_ms=None)

# メイン関数
async def main():
    connect_wifi(SSID, PASSWORD)  # Wi-Fiに接続する
    sync_time()  # NTPで時刻を同期する
    t1 = asyncio.create_task(led_task())
    t2 = asyncio.create_task(sensor_task())
    t3 = asyncio.create_task(peripheral_task())

    await asyncio.gather(t1, t2, t3)

asyncio.run(main())

```
</details>


# スマホFlutter パブリッシャー

<details><summary>main.dart</summary>

```dart
/*
 * @file bme280_ble(main.dart)
 * @brief BLEとMQTTでのデータの受信　Subscribe画面あり ThingSpeak有り
 * @details BLEでデータを受信してMQTTに送信する、画面にはMQTT接続切断ボタン有り
 */

import 'dart:async';
import 'dart:typed_data';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';
import 'dart:convert';
import 'package:http/http.dart' as http; // 追加: ThingSpeak送信用

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter BLE MQTT',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: BLEHomePage(),
    );
  }
}

class BLEHomePage extends StatefulWidget {
  @override
  _BLEHomePageState createState() => _BLEHomePageState();
}

class _BLEHomePageState extends State<BLEHomePage> {
  StreamSubscription<BluetoothAdapterState>? adapterStateSubscription;
  StreamSubscription<bool>? isScanningSubscription;
  StreamSubscription<List<int>>? characteristicSubscription;
  BluetoothDevice? connectedDevice;
  List<BluetoothService> bluetoothServices = [];
  bool isScanning = false;
  String statusMessage = "Checking Bluetooth support...";
  String temperature = "N/A";
  String humidity = "N/A";
  String pressure = "N/A";

  final String pubTopicTemp = 'sensor/temperature';
  final String pubTopicHumidity = 'sensor/humidity';
  final String pubTopicPressure = 'sensor/pressure';
  String mqttStatusMessage = 'Disconnected';
  final client = MqttServerClient('XXXXXXXXX', '');

  int pongCount = 0; // pongCount変数を追加

  // ThingSpeakの設定
  static const String THINGSPEAK_API_KEY = 'XXXXXXX';
  static const String THINGSPEAK_URL = "https://api.thingspeak.com/update";

  @override
  void initState() {
    super.initState();
    initializeBluetooth();
    _connectMQTT();
    thingspeakTask(); // 追加: ThingSpeakタスクの開始
  }

  @override
  void dispose() {
    adapterStateSubscription?.cancel();
    isScanningSubscription?.cancel();
    characteristicSubscription?.cancel();
    connectedDevice?.disconnect();
    _disconnectMQTT();
    super.dispose();
  }

  Future<void> initializeBluetooth() async {
    if (await FlutterBluePlus.isSupported == false) {
      setState(() {
        statusMessage = "Bluetooth not supported by this device";
      });
      print("Bluetooth not supported by this device");
      return;
    }

    adapterStateSubscription =
        FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) {
      print(state);
      if (state == BluetoothAdapterState.on) {
        setState(() {
          statusMessage = "Bluetooth is ON";
          startScanning();
        });
      } else {
        setState(() {
          statusMessage = "Bluetooth is OFF";
          stopScanning();
        });
      }
    });

    if (Platform.isAndroid) {
      await FlutterBluePlus.turnOn();
    }
  }

  void startScanning() {
    if (!isScanning) {
      setState(() {
        isScanning = true;
      });

      FlutterBluePlus.startScan(
        timeout: Duration(seconds: 15),
      ).catchError((e) {
        setState(() {
          statusMessage = "Scan failed: $e";
          isScanning = false;
        });
      });

      FlutterBluePlus.onScanResults.listen(
        (results) {
          if (results.isNotEmpty) {
            for (ScanResult r in results) {
              if (r.advertisementData.localName == "picoLEDTempHumPress") {
                connectToDevice(r.device);
                break;
              }
            }
          }
        },
        onError: (e) => print(e),
      );

      isScanningSubscription = FlutterBluePlus.isScanning.listen((scanning) {
        setState(() {
          isScanning = scanning;
          if (!scanning) {
            statusMessage = "Scan complete";
          }
        });
      });
    }
  }

  void stopScanning() {
    FlutterBluePlus.stopScan();
    setState(() {
      isScanning = false;
    });
  }

  void connectToDevice(BluetoothDevice device) async {
    setState(() {
      statusMessage = "Connecting to ${device.remoteId}";
    });

    await device.connect(autoConnect: true, mtu: null);

    await device.connectionState
        .where((val) => val == BluetoothConnectionState.connected)
        .first;

    setState(() {
      connectedDevice = device;
      statusMessage = "Connected to ${device.remoteId}";
    });
    discoverServices();
  }

  void disconnectFromDevice() async {
    await connectedDevice?.disconnect();
    setState(() {
      connectedDevice = null;
      bluetoothServices.clear();
      statusMessage = "Disconnected";
    });
  }

  void discoverServices() async {
    if (connectedDevice != null) {
      List<BluetoothService> services =
          await connectedDevice!.discoverServices();
      setState(() {
        bluetoothServices = services;
      });

      for (var service in services) {
        for (var characteristic in service.characteristics) {
          if (characteristic.properties.notify) {
            await characteristic.setNotifyValue(true);
            characteristicSubscription =
                characteristic.lastValueStream.listen((value) {
              if (value.isNotEmpty) {
                setState(() {
                  String receivedData = String.fromCharCodes(value);
                  if (characteristic.uuid ==
                      Guid('10F76FC1-D12E-4BF3-9837-9831CF294397')) {
                    temperature = receivedData;
                    _publish(pubTopicTemp, temperature);
                  } else if (characteristic.uuid ==
                      Guid('10F76FC2-D12E-4BF3-9837-9831CF294397')) {
                    humidity = receivedData;
                    _publish(pubTopicHumidity, humidity);
                  } else if (characteristic.uuid ==
                      Guid('10F76FC3-D12E-4BF3-9837-9831CF294397')) {
                    pressure = receivedData;
                    _publish(pubTopicPressure, pressure);
                  }
                });
                print(
                    'Notification received for ${characteristic.uuid}: $String.fromCharCodes(value)');
              } else {
                print('Received empty value for ${characteristic.uuid}');
              }
            });
            connectedDevice!
                .cancelWhenDisconnected(characteristicSubscription!);
          }
        }
      }
    }
  }

  Future<void> _connectMQTT() async {
    client.logging(on: true);
    client.setProtocolV311();
    client.keepAlivePeriod = 20;
    client.connectTimeoutPeriod = 2000;

    client.onDisconnected = onDisconnected;
    client.onConnected = onConnected;
    client.onSubscribed = onSubscribed;
    client.pongCallback = pong;

    final connMess = MqttConnectMessage()
        .withClientIdentifier('BLE_MQTT_Client') // 修正: クライアントIDを変更(同一不可！)
        .withWillTopic('willtopic')
        .withWillMessage('My Will message')
        .startClean()
        .withWillQos(MqttQos.atLeastOnce);
    client.connectionMessage = connMess;

    try {
      await client.connect();
    } on NoConnectionException catch (e) {
      print('EXAMPLE::client exception - $e');
      client.disconnect();
      setState(() {
        mqttStatusMessage = 'Connection failed: $e';
      });
    } on SocketException catch (e) {
      print('EXAMPLE::socket exception - $e');
      client.disconnect();
      setState(() {
        mqttStatusMessage = 'Socket error: $e';
      });
    }

    if (client.connectionStatus!.state == MqttConnectionState.connected) {
      print('EXAMPLE::client connected');
      setState(() {
        mqttStatusMessage = 'Connected';
      });
    } else {
      print(
          'EXAMPLE::ERROR  client connection failed - disconnecting, status is ${client.connectionStatus}');
      client.disconnect();
      setState(() {
        mqttStatusMessage = 'Connection failed';
      });
    }
  }

  void _disconnectMQTT() async {
    await MqttUtilities.asyncSleep(2);
    client.disconnect();
    setState(() {
      mqttStatusMessage = 'Disconnected';
    });
  }

  void _publish(String topic, String message) {
    final builder = MqttClientPayloadBuilder();
    builder.addString(message);
    client.publishMessage(topic, MqttQos.exactlyOnce, builder.payload!);
  }

  // 接続成功時のコールバック
  void onConnected() {
    print(
        'EXAMPLE::OnConnected client callback - Client connection was successful');
    setState(() {
      mqttStatusMessage = 'Connected';
    });
  }

  // サブスクライブ完了時のコールバック
  void onSubscribed(String topic) {
    print('EXAMPLE::Subscription confirmed for topic $topic');
  }

  // 接続が切断されたときのコールバック
  void onDisconnected() {
    print('EXAMPLE::OnDisconnected client callback - Client disconnection');
    if (client.connectionStatus!.disconnectionOrigin ==
        MqttDisconnectionOrigin.solicited) {
      print('EXAMPLE::OnDisconnected callback is solicited, this is correct');
    } else {
      print(
          'EXAMPLE::OnDisconnected callback is unsolicited or none, this is incorrect - exiting');
      exit(-1);
    }
    if (pongCount == 3) {
      print('EXAMPLE:: Pong count is correct');
    } else {
      print('EXAMPLE:: Pong count is incorrect, expected 3. actual $pongCount');
    }
    setState(() {
      mqttStatusMessage = 'Disconnected';
    });
  }

  // PONGメッセージが受信されたときのコールバック
  void pong() {
    print('EXAMPLE::Ping response client callback invoked');
    pongCount++;
  }

// ThingSpeakにデータを送信する関数
  Future<void> sendToThingSpeak(String temp, String hum, String prs) async {
    final url = Uri.parse(
        '$THINGSPEAK_URL?api_key=$THINGSPEAK_API_KEY&field1=$temp&field2=$hum&field3=$prs');
    try {
      final response = await http.get(url);
      print("ThingSpeak response: ${response.body}");
    } catch (e) {
      print("Error sending to ThingSpeak: $e");
    }
  }

// // NTPで時刻を同期する関数
// Future<DateTime> syncTime() async {
//   try {
//     final time = await NTP.now();
//     return time.toUtc().add(Duration(hours: 9)); // 日本標準時に変換
//   } catch (e) {
//     print("Failed to synchronize time: $e");
//     return DateTime.now().toUtc().add(Duration(hours: 9)); // ローカルタイムを使用
//   }
// }

// 毎時00分と30分にThingSpeakにデータを送信する関数
  Future<void> thingspeakTask() async {
    while (true) {
      final currentTime =
          DateTime.now().toUtc().add(Duration(hours: 9)); // ローカルタイムを使用
      if (currentTime.minute == 0 || currentTime.minute == 30) {
        print("Sending data to ThingSpeak at $currentTime");
        sendToThingSpeak(temperature, humidity, pressure);
        await Future.delayed(Duration(minutes: 1)); // 送信後1分間待機して次の送信を防止
      } else {
        await Future.delayed(Duration(seconds: 10)); // 10秒ごとに時間チェック
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Flutter BLE MQTT'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(statusMessage),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: isScanning ? stopScanning : startScanning,
              child: Text(isScanning ? "Stop Scanning" : "Start Scanning"),
            ),
            SizedBox(height: 20),
            if (connectedDevice != null) ...[
              Text('Connected to ${connectedDevice!.remoteId}'),
              ElevatedButton(
                onPressed: disconnectFromDevice,
                child: Text('Disconnect'),
              ),
              SizedBox(height: 20),
              Text(
                'Temperature: $temperature °C',
                style: TextStyle(fontSize: 22),
                overflow: TextOverflow.ellipsis, // テキストが長すぎる場合に省略記号を表示
              ),
              Text(
                'Humidity: $humidity %',
                style: TextStyle(fontSize: 22),
                overflow: TextOverflow.ellipsis, // テキストが長すぎる場合に省略記号を表示
              ),
              Text(
                'Pressure: $pressure hPa',
                style: TextStyle(fontSize: 22),
                overflow: TextOverflow.ellipsis, // テキストが長すぎる場合に省略記号を表示
              ),
            ],
            SizedBox(height: 20),
            Text(
              mqttStatusMessage,
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: _connectMQTT,
              child: Text('Connect to MQTT'),
            ),
            ElevatedButton(
              onPressed: _disconnectMQTT,
              child: Text('Disconnect from MQTT'),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                Navigator.of(context).push(
                  MaterialPageRoute(builder: (_) => SubscribeScreen(client)),
                );
              },
              child: Text('Subscribe Screen'),
            ),
          ],
        ),
      ),
    );
  }
}

class SubscribeScreen extends StatefulWidget {
  final MqttServerClient client;
  SubscribeScreen(this.client);

  @override
  _SubscribeScreenState createState() => _SubscribeScreenState();
}

class _SubscribeScreenState extends State<SubscribeScreen> {
  final String subTopicTemp = 'sensor/temperature';
  final String subTopicHumidity = 'sensor/humidity';
  final String subTopicPressure = 'sensor/pressure';
  String receivedTemperature = '';
  String receivedHumidity = '';
  String receivedPressure = '';
  StreamSubscription? subscription;

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    subscription?.cancel();
    super.dispose();
  }

  void _subscribe() {
    widget.client.subscribe(subTopicTemp, MqttQos.atMostOnce);
    widget.client.subscribe(subTopicHumidity, MqttQos.atMostOnce);
    widget.client.subscribe(subTopicPressure, MqttQos.atMostOnce);
    subscription = widget.client.updates!
        .listen((List<MqttReceivedMessage<MqttMessage?>>? c) {
      final recMess = c![0].payload as MqttPublishMessage;
      final pt = utf8.decode(recMess.payload.message);
      setState(() {
        if (c[0].topic == subTopicTemp) {
          receivedTemperature = pt;
        } else if (c[0].topic == subTopicHumidity) {
          receivedHumidity = pt;
        } else if (c[0].topic == subTopicPressure) {
          receivedPressure = pt;
        }
      });
      print(
          'EXAMPLE::Change notification:: topic is <${c[0].topic}>, payload is <-- $pt -->');
    });
  }

  void _unsubscribe() async {
    await MqttUtilities.asyncSleep(10);
    widget.client.unsubscribe(subTopicTemp);
    widget.client.unsubscribe(subTopicHumidity);
    widget.client.unsubscribe(subTopicPressure);
    subscription?.cancel();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Subscribe Screen'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              padding: EdgeInsets.all(16.0),
              color: Colors.grey[200],
              child: Column(
                children: [
                  Text(
                    receivedTemperature.isEmpty
                        ? 'No temperature data received'
                        : 'Temperature: $receivedTemperature °C',
                    style: TextStyle(fontSize: 16.0),
                  ),
                  Text(
                    receivedHumidity.isEmpty
                        ? 'No humidity data received'
                        : 'Humidity: $receivedHumidity %',
                    style: TextStyle(fontSize: 16.0),
                  ),
                  Text(
                    receivedPressure.isEmpty
                        ? 'No pressure data received'
                        : 'Pressure: $receivedPressure hPa',
                    style: TextStyle(fontSize: 16.0),
                  ),
                ],
              ),
            ),
            ElevatedButton(
              onPressed: _subscribe,
              child: Text('Subscribe'),
            ),
            ElevatedButton(
              onPressed: _unsubscribe,
              child: Text('Unsubscribe'),
            ),
          ],
        ),
      ),
    );
  }
}

```
</details>


## 結果

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806156/17ebcae7-6212-b14e-efa5-56f58d71ae73.jpeg">

# スマホFlutter　サブクライバー
FlutterでMQTT,ThingSpeakからデータを取得してグラフに表示する

<details><summary>main.dart</summary>

```dart
/* flutter版 */
/*
 * @file mqtt_client(main.dart)
 * @brief FlutterでMQTT,ThingSpeakからデータを取得してグラフに表示する
 * @details サブスクライバーのみ、画面ではそれぞれの画面に遷移する
 */

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:fl_chart/fl_chart.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter MQTT Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MQTTPage(),
    );
  }
}

class MQTTPage extends StatefulWidget {
  @override
  _MQTTPageState createState() => _MQTTPageState();
}

class _MQTTPageState extends State<MQTTPage> {
  String mqttStatusMessage = 'Disconnected';
  final client = MqttServerClient('XXXXXXXXXX', '');
  int pongCount = 0;

  @override
  void initState() {
    super.initState();
    _connectMQTT();
  }

  @override
  void dispose() {
    _disconnectMQTT();
    super.dispose();
  }

  Future<void> _connectMQTT() async {
    client.logging(on: true);
    client.setProtocolV311();
    client.keepAlivePeriod = 20;
    client.connectTimeoutPeriod = 2000;

    client.onDisconnected = onDisconnected;
    client.onConnected = onConnected;
    client.onSubscribed = onSubscribed;
    client.pongCallback = pong;

    final connMess = MqttConnectMessage()
        .withClientIdentifier('Subscribe_Only_Client') // 修正: クライアントIDを変更
        .withWillTopic('willtopic')
        .withWillMessage('My Will message')
        .startClean()
        .withWillQos(MqttQos.atLeastOnce);
    client.connectionMessage = connMess;

    try {
      await client.connect();
    } on NoConnectionException catch (e) {
      print('EXAMPLE::client exception - $e');
      client.disconnect();
      setState(() {
        mqttStatusMessage = 'Connection failed: $e';
      });
    } on SocketException catch (e) {
      print('EXAMPLE::socket exception - $e');
      client.disconnect();
      setState(() {
        mqttStatusMessage = 'Socket error: $e';
      });
    }

    if (client.connectionStatus!.state == MqttConnectionState.connected) {
      print('EXAMPLE::Mosquitto client connected');
      setState(() {
        mqttStatusMessage = 'Connected';
      });
    } else {
      print(
          'EXAMPLE::ERROR Mosquitto client connection failed - disconnecting, status is ${client.connectionStatus}');
      client.disconnect();
      setState(() {
        mqttStatusMessage = 'Connection failed';
      });
    }
  }

  void _disconnectMQTT() async {
    await MqttUtilities.asyncSleep(2);
    client.disconnect();
    setState(() {
      mqttStatusMessage = 'Disconnected';
    });
  }

  void _publish(String topic, String message) {
    final builder = MqttClientPayloadBuilder();
    builder.addString(message);
    client.publishMessage(topic, MqttQos.exactlyOnce, builder.payload!);
  }

  // 接続成功時のコールバック
  void onConnected() {
    print(
        'EXAMPLE::OnConnected client callback - Client connection was successful');
    setState(() {
      mqttStatusMessage = 'Connected';
    });
  }

  // サブスクライブ完了時のコールバック
  void onSubscribed(String topic) {
    print('EXAMPLE::Subscription confirmed for topic $topic');
  }

  // 接続が切断されたときのコールバック
  void onDisconnected() {
    print('EXAMPLE::OnDisconnected client callback - Client disconnection');
    if (client.connectionStatus!.disconnectionOrigin ==
        MqttDisconnectionOrigin.solicited) {
      print('EXAMPLE::OnDisconnected callback is solicited, this is correct');
    } else {
      print(
          'EXAMPLE::OnDisconnected callback is unsolicited or none, this is incorrect - exiting');
      exit(-1);
    }
    if (pongCount == 3) {
      print('EXAMPLE:: Pong count is correct');
    } else {
      print('EXAMPLE:: Pong count is incorrect, expected 3. actual $pongCount');
    }
    setState(() {
      mqttStatusMessage = 'Disconnected';
    });
  }

  // PONGメッセージが受信されたときのコールバック
  void pong() {
    print('EXAMPLE::Ping response client callback invoked');
    pongCount++;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Flutter MQTT Demo'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              mqttStatusMessage,
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: _connectMQTT,
              child: Text('Connect to MQTT'),
            ),
            ElevatedButton(
              onPressed: _disconnectMQTT,
              child: Text('Disconnect from MQTT'),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                Navigator.of(context).push(
                  MaterialPageRoute(builder: (_) => SubscribeScreen(client)),
                );
              },
              child: Text('Subscribe Screen'),
            ),
            ElevatedButton(
              onPressed: () {
                Navigator.of(context).push(
                  MaterialPageRoute(builder: (_) => LineChartScreen()),
                );
              },
              child: Text('ThingSpeak Screen'),
            ),
          ],
        ),
      ),
    );
  }
}

class SubscribeScreen extends StatefulWidget {
  final MqttServerClient client;
  SubscribeScreen(this.client);

  @override
  _SubscribeScreenState createState() => _SubscribeScreenState();
}

class _SubscribeScreenState extends State<SubscribeScreen> {
  final String subTopicTemp = 'sensor/temperature';
  final String subTopicHumidity = 'sensor/humidity';
  final String subTopicPressure = 'sensor/pressure';
  String receivedTemperature = '';
  String receivedHumidity = '';
  String receivedPressure = '';
  StreamSubscription? subscription;

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    subscription?.cancel();
    super.dispose();
  }

  void _subscribe() {
    widget.client.subscribe(subTopicTemp, MqttQos.atMostOnce);
    widget.client.subscribe(subTopicHumidity, MqttQos.atMostOnce);
    widget.client.subscribe(subTopicPressure, MqttQos.atMostOnce);
    subscription = widget.client.updates!
        .listen((List<MqttReceivedMessage<MqttMessage?>>? c) {
      final recMess = c![0].payload as MqttPublishMessage;
      final pt = utf8.decode(recMess.payload.message);
      setState(() {
        if (c[0].topic == subTopicTemp) {
          receivedTemperature = pt;
        } else if (c[0].topic == subTopicHumidity) {
          receivedHumidity = pt;
        } else if (c[0].topic == subTopicPressure) {
          receivedPressure = pt;
        }
      });
      print(
          'EXAMPLE::Change notification:: topic is <${c[0].topic}>, payload is <-- $pt -->');
    });
  }

  void _unsubscribe() async {
    await MqttUtilities.asyncSleep(10);
    widget.client.unsubscribe(subTopicTemp);
    widget.client.unsubscribe(subTopicHumidity);
    widget.client.unsubscribe(subTopicPressure);
    subscription?.cancel();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Subscribe Screen'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              padding: EdgeInsets.all(16.0),
              color: Colors.grey[200],
              child: Column(
                children: [
                  Text(
                    receivedTemperature.isEmpty
                        ? 'No temperature data received'
                        : 'Temperature: $receivedTemperature °C',
                    style: TextStyle(fontSize: 16.0),
                  ),
                  Text(
                    receivedHumidity.isEmpty
                        ? 'No humidity data received'
                        : 'Humidity: $receivedHumidity %',
                    style: TextStyle(fontSize: 16.0),
                  ),
                  Text(
                    receivedPressure.isEmpty
                        ? 'No pressure data received'
                        : 'Pressure: $receivedPressure hPa',
                    style: TextStyle(fontSize: 16.0),
                  ),
                ],
              ),
            ),
            ElevatedButton(
              onPressed: _subscribe,
              child: Text('Subscribe'),
            ),
            ElevatedButton(
              onPressed: _unsubscribe,
              child: Text('Unsubscribe'),
            ),
          ],
        ),
      ),
    );
  }
}

class LineChartScreen extends StatefulWidget {
  @override
  _LineChartScreenState createState() => _LineChartScreenState();
}

class _LineChartScreenState extends State<LineChartScreen> {
  // 正規化したデータ
  List<FlSpot> _temperatureData = [];
  List<FlSpot> _humidityData = [];
  List<FlSpot> _pressureData = [];
  List<String> _timeLabels = [];
  // 正規化しないデータ
  List<FlSpot> _temperatureData_nonmix = [];
  List<FlSpot> _humidityData_nonmix = [];
  List<FlSpot> _pressureData_nonmix = [];

  @override
  void initState() {
    super.initState();
    _fetchData();
  }

  Future<void> _fetchData() async {
    final response = await http.get(Uri.parse(
        'https://api.thingspeak.com/channels/XXXXXXX/feeds.json?api_key=XXXXXXXX'));

    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      List feeds = data['feeds'];

      setState(() {
        // 正規化したデータ
        _temperatureData = _generateNormalizedSpots(feeds, 'field1');
        _humidityData = _generateNormalizedSpots(feeds, 'field2');
        _pressureData = _generateNormalizedSpots(feeds, 'field3');
        _timeLabels = _generateTimeLabels(feeds);
        // 正規化しないデータ
        _temperatureData_nonmix = _generateSpots(feeds, 'field1');
        _humidityData_nonmix = _generateSpots(feeds, 'field2');
        _pressureData_nonmix = _generateSpots(feeds, 'field3');
      });
    } else {
      throw Exception('Failed to load data');
    }
  }

  List<FlSpot> _generateSpots(List feeds, String field) {
    List<FlSpot> spots = [];
    for (int i = 0; i < feeds.length; i++) {
      double x = i.toDouble();
      double y = double.tryParse(feeds[i][field].toString()) ?? 0.0;
      spots.add(FlSpot(x, y));
    }
    return spots;
  }

  List<FlSpot> _generateNormalizedSpots(List feeds, String field) {
    List<FlSpot> spots = [];
    double minY = double.infinity;
    double maxY = -double.infinity;

    // 最小値と最大値を取得
    for (var feed in feeds) {
      double y = double.tryParse(feed[field].toString()) ?? 0.0;
      if (y < minY) minY = y;
      if (y > maxY) maxY = y;
    }

    // 正規化してFlSpotのリストを作成
    for (int i = 0; i < feeds.length; i++) {
      double x = i.toDouble();
      double y = double.tryParse(feeds[i][field].toString()) ?? 0.0;
      double normalizedY = (y - minY) / (maxY - minY); // 正規化
      spots.add(FlSpot(x, normalizedY));
    }

    return spots;
  }

  // List<String> _generateTimeLabels(List feeds) {
  //   List<String> labels = [];
  //   for (int i = 0; i < feeds.length; i++) {
  //     DateTime dateTime = DateTime.parse(feeds[i]['created_at']);
  //     String dateLabel = '${dateTime.month}/${dateTime.day}';
  //     String timeLabel =
  //         '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';

  //     if ((dateTime.hour == 0 && dateTime.minute == 0) ||
  //         (dateTime.hour == 12 && dateTime.minute == 0) ||
  //         i == feeds.length - 1) {
  //       labels.add('$dateLabel $timeLabel');
  //     } else {
  //       labels.add('');
  //     }
  //   }
  //   return labels;
  // }
  List<String> _generateTimeLabels(List feeds) {
    List<String> labels = [];
    for (int i = 0; i < feeds.length; i++) {
      DateTime dateTime =
          DateTime.parse(feeds[i]['created_at']).toLocal(); // 東京ローカル時刻に変換
      String dateLabel = '${dateTime.month}/${dateTime.day}';
      String timeLabel =
          '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';

      // if ((dateTime.hour == 0 && dateTime.minute == 0) ||
      //     (dateTime.hour == 12 && dateTime.minute == 0) ||
      //     i == feeds.length - 1) {
      //   labels.add('$dateLabel $timeLabel');
      // } else {
      //   labels.add('');
      // }
      if (i == 0 || i == 50 || (i == feeds.length - 1)) {
        labels.add('$dateLabel $timeLabel');
        print('Label added at index $i: $dateLabel $timeLabel');
      } else {
        labels.add('');
      }
    }
    print('Total feeds: ${feeds.length}');
    return labels;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Line Chart from JSON'),
      ),
      body: _temperatureData.isEmpty ||
              _humidityData.isEmpty ||
              _pressureData.isEmpty
          ? Center(child: CircularProgressIndicator())
          : SingleChildScrollView(
              child: Column(
                // 正規化しないデータを温度、湿度、圧力のグラフに渡す
                children: [
                  _buildLineChart('Temperature', _temperatureData_nonmix),
                  _buildLineChart('Humidity', _humidityData_nonmix),
                  _buildLineChart('Pressure', _pressureData_nonmix),
                  // 正規化したデータを混合グラフには渡す
                  _buildCombinedLineChart(
                    'Combined Chart',
                    _temperatureData,
                    _humidityData,
                    _pressureData,
                  ),
                ],
              ),
            ),
    );
  }

  Widget _buildLineChart(String title, List<FlSpot> data) {
    double minY = data.map((spot) => spot.y).reduce((a, b) => a < b ? a : b);
    double maxY = data.map((spot) => spot.y).reduce((a, b) => a > b ? a : b);
    print('minY: $minY, maxY: $maxY');
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        children: [
          Text(title,
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
          SizedBox(
            height: 400,
            child: LineChart(
              LineChartData(
                gridData: FlGridData(show: true),
                titlesData: FlTitlesData(
                  leftTitles: AxisTitles(
                    sideTitles: SideTitles(
                      showTitles: true,
                      interval: ((maxY - minY) / 5).toDouble(),
                      getTitlesWidget: (value, meta) => Text(
                        value.toStringAsFixed(1),
                        style: TextStyle(
                          color: Colors.black,
                          fontWeight: FontWeight.bold,
                          fontSize: 12,
                        ),
                      ),
                      reservedSize: 28,
                    ),
                  ),
                  bottomTitles: AxisTitles(
                    sideTitles: SideTitles(
                      showTitles: true,
                      // interval: (_timeLabels.length / 5).toDouble(),
                      getTitlesWidget: (value, meta) {
                        int index = value.toInt();
                        // if (index >= 0 && index < _timeLabels.length) {
                        if (index == 0 ||
                            index == 50 ||
                            index == _timeLabels.length - 1) {
                          return Text(_timeLabels[index],
                              style: TextStyle(
                                color: Colors.black,
                                fontWeight: FontWeight.bold,
                                fontSize: 12,
                              ));
                        }
                        return Text('');
                      },
                      reservedSize: 22,
                    ),
                  ),
                  rightTitles: AxisTitles(
                    sideTitles: SideTitles(showTitles: false),
                  ),
                  topTitles: AxisTitles(
                    sideTitles: SideTitles(showTitles: false),
                  ),
                ),
                borderData: FlBorderData(show: true),
                minX: 0,
                maxX: (data.length - 1).toDouble(),
                minY: minY,
                maxY: maxY,
                lineBarsData: [
                  LineChartBarData(
                    spots: data,
                    isCurved: true,
                    barWidth: 2,
                    color: Colors.blue,
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  //

// ... existing code ...
  Widget _buildCombinedLineChart(
    String title,
    List<FlSpot> tempData,
    List<FlSpot> humidityData,
    List<FlSpot> pressureData,
  ) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        children: [
          Text(title,
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
          SizedBox(
            height: 400,
            child: LineChart(
              LineChartData(
                gridData: FlGridData(show: true),
                titlesData: FlTitlesData(
                  leftTitles: AxisTitles(
                    sideTitles: SideTitles(
                      showTitles: true,
                      interval: 0.2,
                      getTitlesWidget: (value, meta) => Text(
                        value.toStringAsFixed(1),
                        style: TextStyle(
                          color: Colors.black,
                          fontWeight: FontWeight.bold,
                          fontSize: 12,
                        ),
                      ),
                      reservedSize: 28,
                    ),
                  ),
                  bottomTitles: AxisTitles(
                    sideTitles: SideTitles(
                      showTitles: true,
                      getTitlesWidget: (value, meta) {
                        int index = value.toInt();
                        if (index == 0 ||
                            index == 50 ||
                            index == _timeLabels.length - 1) {
                          return Text(_timeLabels[index],
                              style: TextStyle(
                                color: Colors.black,
                                fontWeight: FontWeight.bold,
                                fontSize: 12,
                              ));
                        }
                        return Text('');
                      },
                      reservedSize: 22,
                    ),
                  ),
                  rightTitles: AxisTitles(
                    sideTitles: SideTitles(showTitles: false),
                  ),
                  topTitles: AxisTitles(
                    sideTitles: SideTitles(showTitles: false),
                  ),
                ),
                borderData: FlBorderData(show: true),
                minX: 0,
                maxX: (tempData.length - 1).toDouble(),
                minY: 0,
                maxY: 1,
                lineBarsData: [
                  LineChartBarData(
                    spots: tempData,
                    isCurved: true,
                    barWidth: 2,
                    color: Colors.blue,
                    dotData: FlDotData(show: false),
                    belowBarData: BarAreaData(show: false),
                  ),
                  LineChartBarData(
                    spots: humidityData,
                    isCurved: true,
                    barWidth: 2,
                    color: Colors.green,
                    dotData: FlDotData(show: false),
                    belowBarData: BarAreaData(show: false),
                  ),
                  LineChartBarData(
                    spots: pressureData,
                    isCurved: true,
                    barWidth: 2,
                    color: Colors.red,
                    dotData: FlDotData(show: false),
                    belowBarData: BarAreaData(show: false),
                  ),
                ],
              ),
            ),
          ),
          // Add a legend
          Container(
            height: 40, // 例: 40の高さを設定
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                _buildLegendItem('Temperature', Colors.blue),
                SizedBox(width: 10),
                _buildLegendItem('Humidity', Colors.green),
                SizedBox(width: 10),
                _buildLegendItem('Pressure', Colors.red),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildLegendItem(String label, Color color) {
    return Row(
      children: [
        Container(
          width: 10,
          height: 10,
          color: color,
        ),
        SizedBox(width: 5),
        Text(label),
      ],
    );
  }
// ... existing code ...
}


```
</details>


## 結果
### Thingspeak

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806156/bd6c49cd-0e8f-f8aa-2de1-c5f379d94344.jpeg">

### Flutter
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806156/3e6094ba-f887-6a86-dc3c-cc8d18709eff.jpeg">
<BR>
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806156/eb9bd86c-87b5-9d9c-5b29-60e47b2d31a1.jpeg">
<BR>
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806156/868a3207-90d0-fd1b-0d59-78df069a3bf6.jpeg">
<BR>
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806156/99089a84-8734-bc2a-3b0a-04b7a4237a68.jpeg">
<BR>
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3806156/03378314-76d9-56fe-8d28-5b97e934a68f.jpeg">