【ESP32 × Flutter】Wi-Fiスキャン結果をBLEでチャンク分割送信する方法

この記事は約23分で読めます。

はじめに

以前、猫型おしゃべりロボット「ミーア」に関する開発記事で、「Wi-Fiネットワークの探索機能をESP32に実装し、Bluetooth経由でFlutterアプリに送信する機能」について記載した。

最初はエラーなくスキャンしたWi-Fiリストが表示されていたが、ある時下記のエラーが出現した。

PlatformIO(ESP32)のログを見ると、下記のエラーが表示されていた。

設定しようとしているデータのサイズは724バイトだが、ESP32のBLE特性に許可されている最大サイズは600バイトであり、データのサイズが大きすぎるためBLE送信できないというエラー。

ShellScript
BLEServer connected
Scanning WiFi networks...
Scan complete.
Available networks:
Size of data to send: 724 bytes
[ 68429][E][BLECharacteristic.cpp:662] setValue(): Size 724 too large, must be no bigger than 600

現状のコード確認

現状、ESP32からWi-Fiスキャン結果をBluetooth通信でFlutterアプリに送信しているコードがこちら。Wi-Fiスキャン結果をJSON形式の文字列にシリアル化して、一括送信している。

C++
// src/BLEManager.cpp

// Wi-FiネットワークリストをJSON形式の文字列にシリアル化
String serializeNetworks(const std::vector<NetworkInfo> &networks) {
  DynamicJsonDocument doc(1024);
  JsonArray array = doc.to<JsonArray>();

  for (const auto &network : networks) {
    JsonObject obj = array.createNestedObject();
    obj["ssid"] = network.ssid;
    obj["rssi"] = network.rssi;
    obj["encryptionType"] = network.encryptionType;
  }

  String result;
  serializeJson(doc, result);
  return result;
}

void BLEManager::CharacteristicCallbacks::onWrite(
    BLECharacteristic *pCharacteristic) {
  std::string value = pCharacteristic->getValue();

  if (value.length() > 0) {
    std::string ssid;
    std::string password;
    std::string response;

    // Wi-Fiネットワークリストの要求を処理する
    if (value == "request_wifi_list") {
      // 利用可能なWi-Fiネットワークをスキャン
      std::vector<NetworkInfo> networks = scanNetworks();

      // スキャン結果をシリアル化
      String networkListJson = serializeNetworks(networks);
      Serial.printf("networkListJson: %sn", networkListJson.c_str());

      // 送信データのサイズをログ出力
      Serial.printf("Size of data to send: %d bytesn", networkListJson.length());

      // シリアル化されたリストをBLE経由で送信
      pCharacteristic->setValue(networkListJson.c_str());
      pCharacteristic->notify();
      Serial.println("WiFi network list sent via BLE");

      return;
    }
  }
}

受け取り側のFlutterでは、fluter_blue_plusプラグインを使用しており、AndroidデバイスでのデフォルトMTU(Maximum Transmission Unit:BLEデバイス間で交換される最大データサイズを定義する値)は512バイトとして自動的にリクエストされ、iOSおよびmacOSではMTUが自動的に交渉される。

https://pub.dev/packages/flutter_blue_plus

というわけで、今回の場合2つの対応策があると思う。

1)大きなデータを複数のメッセージに分割して送信する
2)キー名を短くすることでJSONのサイズを削減する

どちらも行おうと思う。まずは、簡易な2から。

JSONキー名を短くしてJSONサイズを削減

現状、スキャンしたWifiリストをBLE送信する際は、下記のようなリスト構造になっていてキー名がやや長い。

ShellScript
{"ssid":"XXXXXXX-X-XXXXX","rssi":-56,"encryptionType":3}

Wi-FiネットワークリストをJSON形式の文字列にシリアル化する部分で、ssidsに、rssirに、encryptionTypeeにするなどして、キー名を短縮する。

C++
// src/BLEManager.cpp
// Wi-FiネットワークリストをJSON形式の文字列にシリアル化
String serializeNetworks(const std::vector<NetworkInfo> &networks) {
  DynamicJsonDocument doc(1024);
  JsonArray array = doc.to<JsonArray>();

  for (const auto &network : networks) {
    JsonObject obj = array.createNestedObject();
    obj["s"] = network.ssid;
    obj["r"] = network.rssi;
    obj["e"] = network.encryptionType;
  }

  String result;
  serializeJson(doc, result);
  return result;
}

JSONキー名を変更したので、Flutter側(アプリ)のコードもそれに合わせて変更する必要がある。具体的には、WifiNetworkクラスのファクトリメソッド fromJson とそれを使用している部分で、JSONのキー名を新しい短いキーに合わせて更新する。

Dart
// lib/services/ble_service.dart
// Wi-Fiネットワーク情報を保持するクラス
class WifiNetwork {
  final String ssid;
  final int rssi;
  final int encryptionType;

  WifiNetwork(
      {required this.ssid, required this.rssi, required this.encryptionType});

  factory WifiNetwork.fromJson(Map<String, dynamic> json) {
    return WifiNetwork(
      ssid: json['ssid'] as String,
      rssi: json['rssi'] as int,
      encryptionType: json['encryptionType'] as int,
    );
  }
}

動作確認

600バイトを超えていてBLE送信エラーとなっていたWifiリストが、JSONキーの短縮により479バイトまで抑えられて無事送信できた。

ShellScript
execute 1 minute action
Scanning WiFi networks...
12
Scan complete.
Available networks:
SSID / RSSI / Encryption
Size of data to send: 479 bytes
WiFi network list sent via BLE

しかし、これは根本的な解決策にはなっておらず、仮に商業施設など多くのWi-Fiが飛び交っている状況ではWi-Fiスキャン結果が100近くになることも想定されるため、その場合は、BLE送信サイズ超過エラーは解消できない。

なので、BLE分割送信を実装する必要がある。

データ分割による複数メッセージでの送信

PlatformIO (ESP32) :チャンクに分けて送信

WiFiネットワークリストをチャンクに分けて送信する関数を作成する。

データがBLEの最大ペイロードサイズ(通常は512バイト)に達するたびにBLEを通じてデータを送信し、新しいチャンクを開始する。

C++
// BLEManager.hに追加
void sendDataInChunks(const std::string& data, unsigned int chunkSize);

// BLEManager.cppに実装を追加
void BLEManager::sendDataInChunks(const std::string& data, unsigned int chunkSize) {
    size_t numChunks = data.length() / chunkSize + (data.length() % chunkSize != 0);
    for (size_t i = 0; i < numChunks; i++) {
        size_t start = i * chunkSize;
        std::string chunk = data.substr(start, chunkSize);
        _pCharacteristic->setValue(chunk);
        _pCharacteristic->notify();
        delay(100);
    }
}

void BLEManager::CharacteristicCallbacks::onWrite(BLECharacteristic *pCharacteristic) {
    std::string value = pCharacteristic->getValue();

    if (value == "request_wifi_list") {
        std::vector<NetworkInfo> networks = scanNetworks();
        String networkListJson = serializeNetworks(networks);
        Serial.printf("networkListJson: %sn", networkListJson.c_str());
        Serial.printf("Size of data to send: %d bytesn", networkListJson.length());

        // データをチャンクに分けて送信。512はBLEのペイロードサイズ
        sendDataInChunks(networkListJson.c_str(), 512);
        Serial.println("WiFi network list sent via BLE in chunks");
    }
}

BLEの特性の一部として定義されるDescriptorにCCCDを追加

分割送信を行う場合、BLEの通知(Notify)機能を利用することで、ESP32がデータの準備ができた時点で即座にアプリにデータを送信できる。これを実現するためには、特性に対して「Client Characteristic Configuration Descriptor(CCCD)」を設定する必要がある。

http://marchan.e5.valueserver.jp/cabin/comp/jbox/arc212/doc21201.html

C++
// BLEManager.hに追加
#include <BLE2902.h>

// BLEManager.cppに実装を追加
void BLEManager::initialize() {
  // Initialize the BLE Device
  BLEDevice::init("ミーア V1");

  // Create the BLE Server
  _pServer = std::unique_ptr<BLEServer>(BLEDevice::createServer());
  _pServer->setCallbacks(&serverCallbacks);

  // Create the BLE Service
  BLEService *pService = _pServer->createService(SERVICE_UUID);

  // Create a BLE Characteristic
  _pCharacteristic = std::unique_ptr<BLECharacteristic>(
      pService->createCharacteristic(
          CHARACTERISTIC_UUID,
          BLECharacteristic::PROPERTY_READ |
              BLECharacteristic::PROPERTY_WRITE |
              BLECharacteristic::PROPERTY_NOTIFY |
              BLECharacteristic::PROPERTY_INDICATE));
              
  // Create a BLE Descriptor (CCCD)
  _pCharacteristic->addDescriptor(new BLE2902());
  _pCharacteristic->setCallbacks(&characteristicCallbacks);
  pService->start();
}

Flutter (アプリ):分割されたデータから再構築

通知の設定

  • 特性に対して setNotifyValue(true) を呼び出し、BLEデバイスからのデータを非同期に受信できるようにする。これにより、デバイスが新しいデータを送信するたびにFlutterアプリに通知され、即座にデータを受け取ることができる。
  • 特性の通知を有効にした後(setNotifyValue(true)をコールした後)、lastValueStreamを購読することで、その特性から送られてくるデータの変更をリアルタイムで受け取れる。

flutter_blue_plusパッケージの、Last Value Streamのセクション参考

https://pub.dev/packages/flutter_blue_plus

分割送信の再構成方法

  • 受信したデータをStringBufferに追加し、データがJSONの開始([)で始まり、終了(])で終わるかをチェックする。全てのチャンクが受信され、有効なJSON文字列が形成された場合、それを解析しWifiNetworkオブジェクトのリストに変換する。
Dart
// lib/services/ble_service.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:your_app/models/wifi_network.dart';

class BLEService {
  FlutterBluePlus flutterBlue = FlutterBluePlus.instance;
  final String serviceUUID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx";
  final String characteristicUUID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx";

  Future<List<WifiNetwork>> requestWifiList(BluetoothDevice device) async {
    final completer = Completer<List<WifiNetwork>>();
    List<WifiNetwork> wifiList = [];
    StringBuffer buffer = StringBuffer();
    bool isStartChunk = true;
    bool isEndChunk = false;

    try {
      // デバイスに接続
      await device.connect();

      // サービスを探索
      List<BluetoothService> services = await device.discoverServices();
      BluetoothService service =
          services.firstWhere((s) => s.uuid == clockyServiceUUID);
      BluetoothCharacteristic characteristic =
          service.characteristics.firstWhere((c) => c.uuid == wifiUuid);

      // 通知を有効にする
      await characteristic.setNotifyValue(true);

      // Wi-Fi リスト要求を送信
      await characteristic.write(utf8.encode("request_wifi_list"));

      // ストリームを購読し、受信したデータをバッファリング
      Stream<List<int>> stream = characteristic.lastValueStream;
      stream.listen((data) {
        String decodedData = utf8.decode(data);
        print('Received chunk: $decodedData');

        if (isStartChunk) {
          if (decodedData.startsWith('[')) {
            buffer.write(decodedData);
            isStartChunk = false;
            isEndChunk = false;
          }
        } else {
          if (decodedData.endsWith(']')) {
            buffer.write(decodedData);
            isEndChunk = true;
          } else {
            buffer.write(decodedData);
          }
        }

        if (isEndChunk) {
          String jsonData = buffer.toString();
          print("Received JSON data: $jsonData");

          try {
            final jsonResponse = jsonDecode(jsonData) as List<dynamic>;
            wifiList = jsonResponse.map((networkJson) {
              final networkMap = networkJson as Map<String, dynamic>;
              return WifiNetwork.fromJson(networkMap);
            }).toList();
            
            if (!completer.isCompleted) {
              buffer.clear();
              completer.complete(wifiList);
            }
          } catch (e) {
            print('Error parsing JSON data: $e');
          }
          isStartChunk = true;
          isEndChunk = false;
        }
      });

      // ストリームが完了したらリストを返す
      return completer.future;
    } catch (e) {
      print('Error occurred while requesting Wi-Fi list: $e');
      rethrow;
    }
  }
}

Completerのと使用と、複数回のリスキャン時のエラー回避

Completerの目的

  • Completerは非同期操作が完了したことを外部に通知するために使う。このケースでは、全てのデータが正しく受信・解析された後に、Futureを完成させ、Wi-Fiネットワークリストを呼び出し元に返すために使用。
  • Completerがない場合、すべてのデータが到着していると確認する方法がなく、不完全なデータで処理を進めてしまい、アプリにWifiスキャン結果のリストが表示されない。

複数回リスキャン時のエラー回避

  • Completerが一度完成された後、もう一度「Wi-Fiを探す」ボタンを押すと、すでに完了したCompleterに対してまた.complete()を呼び出そうとしてエラーが発生する。一度Completerが完了すると(つまり、.complete()メソッドが呼び出されると)、再度同じCompleter.complete()を呼び出すことはできないため。
  • これを防ぐために、Completerの状態をチェックし、未完成の場合のみ完成させるようにする。また、各スキャン操作の開始時に新しいCompleterインスタンスを作成することで、前の操作の状態から独立させる。
Dart
Future<List<WifiNetwork>> requestWifiList(BluetoothDevice device) async {
  // スキャンごとに新しいCompleterを作成
  final completer = Completer<List<WifiNetwork>>();
  
  try {
    // ... (デバイスへの接続コードなど)

    // ストリームリスナーコールバック
    Stream<List<int>> stream = characteristic.lastValueStream;
    stream.listen((data) {
      String decodedData = utf8.decode(data);

      // ... (チャンク処理とJSONデータ解析のコード)

      if (isEndChunk) {
        // 完全なJSONデータを処理

        // 解析前にJSONデータを検証
        try {
          // Completerが完了していないことを確認
          if (!completer.isCompleted) {
            buffer.clear();
            completer.complete(wifiList);
          }
        } catch (e) {
          print('Error parsing JSON data: $e');
        }
      }
    });
    
    // CompleterのFutureを返す
    return completer.future;
  } catch (e) {
    print('Error occurred while requesting Wi-Fi list: $e');
    rethrow;
  }
}

動作確認

PlatformIO(ESP32)

512バイトを超えるデータの場合でも無事送信できている

ShellScript
Scanning WiFi networks...
13
Scan complete.
Available networks:
SSID / RSSI / Encryption
Size of data to send: 720 bytes
WiFi network list sent via BLE in chunks

Flutter

ESP32から分割送信されるWi-Fiスキャン結果に対して、有効なJSON文字列が形成された場合にのみ、JSON dataとしてWi-Fiリストを再構成できていることが分かる。

ShellScript
flutter: Received chunk: P-337H-G","rssi":-87,"encryptionType":4},{"ssid":"********-W","rssi":-88,"encryptionType":3},<br>flutter: Received chunk: [{"ssid":"********-W","rssi":-64,"encryptionType":3},{"ssid":"********-W","rssi":-71,"encryptionType":3},{"ssid":"AP_sta<br>flutter: Received chunk: ff_3F_2.4","rssi":-88,"encryptionType":4},{"ssid":"********-W","rssi":-89,"encryptionType":4}]<br>flutter: Received JSON data: [{"ssid":"********-W","rssi":-64,"encryptionType":3},{"ssid":"********-W","rssi":-71,"encryptionType":3},{"ssid":"********-W","rssi":-79,"encryptionType":3}]

アプリ画面にもWifiリストが無事表示された。

スキャン結果Wi-Fi数が画面縦幅を超える場合には、スクロールして表示している。「もう一度探す」ボタンをクリックした時も無事表示されることを確認できた。

コメント

タイトルとURLをコピーしました