flutter_blue_plus を使った toio への BLE接続／切断や書き込み（音の再生）を Flutter で実装してみる： Android Studio で Androidアプリをビルド

## はじめに

最近、Flutter でのスマホアプリ開発を試しています。

さらに、最近 BLE関連の実装を試していて、それに関連した試作です。


### 試してみる内容

今回試す内容は、Flutterアプリ（Androidアプリ）で、toio との BLE通信を試してみるというものです。

#### 元にする内容

その際、以前自分が書いた以下の 2つの記事の内容を元にします。

●flutter_blue_plus を使った「BLEスキャン＋ micro:bit への接続／切断」を Flutter で実装してみる： Android Studio で Androidアプリをビルド - Qiita
　https://qiita.com/youtoy/items/c5b70869f152ca5d29f5

●ブラウザの Web Bluetooth API を使って toio で使える効果音のいくつかをセットで鳴らす（環境は p5.js Web Editor を利用） - Qiita
　https://qiita.com/youtoy/items/00abe2b06e23bf9e0f09

具体的には、これらの記事内に掲載している Flutterアプリの実装と、Web Bluetooth API（ブラウザ上の JavaScript）を使った実装を、合わせて ChatGPT（o3-mini-high） に読み込ませてプログラムを作成してもらいます。

プロンプトは比較的シンプルな内容で試していきました。


## 実際に試す

実際に試していきます。

### 下準備

今回、Androidアプリで BLE を使うので、パーミッションの追加などが必要です。

そのあたりの話は、[以前書いた記事の説明](https://qiita.com/youtoy/items/66b0563005c62b926ce1#%E3%83%91%E3%83%BC%E3%83%9F%E3%83%83%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AE%E8%BF%BD%E5%8A%A0)をご覧ください。

また、以下のパッケージを使えるようにしておいてください。

●flutter_blue_plus | Flutter package
　https://pub.dev/packages/flutter_blue_plus

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/0110ef1a-f18e-4f9f-92ad-2a3dded956d8.png)

### コード

ChatGPT に、2つの参考となるコードを参照してもらいつつ、書いてもらったコードが以下となります。
（実際には、出力されたものを動かしてみて、少し手直しをしたりなどしています）


```dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';

/// toio のサービス UUID とサウンドキャラクタリスティック UUID
const String TOIO_SERVICE_UUID = "10b20100-5b3b-4571-9508-cf3efcd7bbae";
const String TOIO_SOUND_CHARACTERISTIC_UUID = "10b20104-5b3b-4571-9508-cf3efcd7bbae";

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'toio BLE Connect Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'toio BLE Connect Demo'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  StreamSubscription<List<ScanResult>>? _scanSubscription;
  BluetoothDevice? _targetDevice;
  BluetoothCharacteristic? _soundCharacteristic;
  String _statusMessage = 'スキャン開始ボタンを押してください';

  /// デバイス名に "toio" が含まれるデバイスをスキャンして接続する
  Future<void> _startScan() async {
    setState(() {
      _statusMessage = 'スキャン中...';
      _targetDevice = null;
    });

    await FlutterBluePlus.adapterState
        .firstWhere((state) => state == BluetoothAdapterState.on);

    bool foundDevice = false;
    _scanSubscription =
        FlutterBluePlus.onScanResults.listen((List<ScanResult> results) async {
          for (var result in results) {
            final deviceName = result.device.name;
            // デバイス名に "toio" が含まれているかチェック（大文字小文字を区別しない）
            if (deviceName.toLowerCase().contains("toio")) {
              if (!foundDevice) {
                foundDevice = true;
                _scanSubscription?.cancel();
                setState(() {
                  _statusMessage =
                  'デバイス見つかりました: $deviceName\n接続中...';
                });
                _targetDevice = result.device;
                try {
                  // 接続（autoConnect は false に設定）
                  await _targetDevice!.connect(autoConnect: false);
                  // 接続状態が connected になるのを待つ
                  await _targetDevice!.connectionState.firstWhere(
                          (state) => state == BluetoothConnectionState.connected);

                  // サービス探索
                  List<BluetoothService> services =
                  await _targetDevice!.discoverServices();
                  BluetoothService? toioService;
                  for (BluetoothService service in services) {
                    if (service.uuid.toString().toLowerCase() ==
                        TOIO_SERVICE_UUID.toLowerCase()) {
                      toioService = service;
                      break;
                    }
                  }
                  // サウンドキャラクタリスティックの取得
                  if (toioService != null) {
                    for (BluetoothCharacteristic c in toioService.characteristics) {
                      if (c.uuid.toString().toLowerCase() ==
                          TOIO_SOUND_CHARACTERISTIC_UUID.toLowerCase()) {
                        _soundCharacteristic = c;
                        break;
                      }
                    }
                  }
                  setState(() {
                    _statusMessage = 'デバイスに接続完了: $deviceName';
                  });
                } catch (e) {
                  setState(() {
                    _statusMessage = '接続エラー: $e';
                  });
                }
                break;
              }
            }
          }
        });

    // 15秒間スキャンを実施
    await FlutterBluePlus.startScan(timeout: const Duration(seconds: 15));
    await FlutterBluePlus.isScanning
        .firstWhere((isScanning) => isScanning == false);
    if (!foundDevice) {
      setState(() {
        _statusMessage = 'toio が見つかりませんでした';
      });
    }
  }

  /// 接続中のデバイスとの切断処理
  Future<void> _disconnectDevice() async {
    if (_targetDevice != null) {
      try {
        await _targetDevice!.disconnect();
        setState(() {
          _statusMessage = 'デバイスとの接続を切断しました';
          _targetDevice = null;
          _soundCharacteristic = null;
        });
      } catch (e) {
        setState(() {
          _statusMessage = '切断エラー: $e';
        });
      }
    } else {
      setState(() {
        _statusMessage = '接続中のデバイスはありません';
      });
    }
  }

  /// 効果音を鳴らす処理（サウンドキャラクタリスティックに書き込み）
  Future<void> _playSound() async {
    if (_soundCharacteristic == null) {
      setState(() {
        _statusMessage = 'サウンドキャラクタリスティックが取得できていません';
      });
      return;
    }
    try {
      // 指定の遅延後に書き込みを実施
      await Future.delayed(const Duration(milliseconds: 200));
      await _soundCharacteristic!
          .write([0x02, 0x04, 0xff], withoutResponse: false);
      await Future.delayed(const Duration(milliseconds: 800));
      await _soundCharacteristic!
          .write([0x02, 7, 0xff], withoutResponse: false);
      await Future.delayed(const Duration(milliseconds: 800));
      await _soundCharacteristic!
          .write([0x02, 9, 0xff], withoutResponse: false);
      await Future.delayed(const Duration(milliseconds: 800));
      await _soundCharacteristic!
          .write([0x02, 10, 0xff], withoutResponse: false);
      setState(() {
        _statusMessage = '効果音を鳴らしました';
      });
    } catch (e) {
      setState(() {
        _statusMessage = '効果音の再生エラー: $e';
      });
    }
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Text(
          _statusMessage,
          textAlign: TextAlign.center,
          style: const TextStyle(fontSize: 18),
        ),
      ),
      // 接続ボタン、効果音再生ボタン、切断ボタンを横に並べる
      floatingActionButton: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          FloatingActionButton(
            heroTag: 'scan',
            onPressed: _startScan,
            tooltip: 'BLEスキャン',
            child: const Icon(Icons.search),
          ),
          const SizedBox(width: 16),
          FloatingActionButton(
            heroTag: 'playSound',
            onPressed: _playSound,
            tooltip: '効果音再生',
            child: const Icon(Icons.volume_up),
          ),
          const SizedBox(width: 16),
          FloatingActionButton(
            heroTag: 'disconnect',
            onPressed: _disconnectDevice,
            tooltip: '切断',
            child: const Icon(Icons.link_off),
          ),
        ],
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
    );
  }
}
```

### アプリを動作させた結果

上記を Androidスマホで動作させ、実行した時の様子は以下のとおりです。

アプリ上の 3つのボタンをそれぞれ押した際に「toio との接続」「音の再生」「toio との接続の切断」が行えていることが分かります（アプリのボタン押下時に、それらに対応する音が toio から鳴っています）。

<iframe width="560" height="315" src="https://www.youtube.com/embed/9ZcbQOR97iQ?si=5UlwjBfml17FdB-u" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


ChatGPT（o3-mini-high）が良い感じにコードを書いてくれたので、やりたいことをサクッと進められました。