0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ESP32とWindows間のBLE通信、開通から速度向上まで

0
Posted at

はじめに

IoTデバイスとPC間の無線通信において、BLE通信はIoTデバイスにとって貴重な電力の消費を抑えつつ、外部のデバイスとデータをやり取りするための有効な選択肢です。ここでは、BLE規格を策定した人たちの意図と電源事情を完全に無視して、ESP32とWindows PCで通信速度を可能な限り向上させることを目標にした、試行錯誤の結果を共有します。

検討過程

以下の環境を準備し、複数のフェーズに分けて検討を進めました。

環境

  • ESP32 WROOM 32E
  • スマートフォンとWindows11 PC

フェーズ

  • フェーズ1 BLE通信の確立
  • フェーズ2 Windowsアプリ開発
  • フェーズ3 実力把握
  • フェーズ4 高速化

フェーズ1:BLE通信の確立(ESP32 + Nordicアプリ)

ESP32については電子工作などである程度は扱ったことがあるものの、BLEについては名前を知っている程度、つまり、ほぼ何も知らない状態からのスタートです。
BLEの通信規格を知ることも大事ですが、すでに規格化されているものは試しながら学ぶスタイルが好みのため、送信のコード例などを簡単に調査した後、早速試すことにします。ESP32でデータを送信する実装に注力したいため、データを受信するアプリは既存のNordic社製のnRF Connectを使う前提で検討を進めました。また、データ量は少量で良く、データはNotifyで送信する、としました。

ESP32から送信するコード

ESP32からNotifyで送信するコードは以下のようにしました。データの送受信が確認できればよいため、データ量は4バイト、送信間隔は1000msecと少なくしました。

ESP32側のサンプルコード
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

// BLE設定
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define DEVICE_NAME "ESP32-BLE-Server"

// データ送信設定
#define SEND_INTERVAL_MS 1000 // 送信間隔(ミリ秒)
#define DATA_SIZE 4           // 送信データサイズ(バイト)

// グローバル変数
BLEServer *pServer = NULL;
BLECharacteristic *pCharacteristic = NULL;
bool deviceConnected = false;
bool oldDeviceConnected = false;

// 送信データ
uint32_t dataCounter = 0;
unsigned long lastSendTime = 0;

// サーバーコールバッククラス
class MyServerCallbacks : public BLEServerCallbacks
{
    void onConnect(BLEServer *pServer)
    {
        deviceConnected = true;
        Serial.println("デバイスが接続されました");
    }

    void onDisconnect(BLEServer *pServer)
    {
        deviceConnected = false;
        Serial.println("デバイスが切断されました");
    }
};

// キャラクタリスティックコールバッククラス
class MyCharacteristicCallbacks : public BLECharacteristicCallbacks
{
    void onWrite(BLECharacteristic *pCharacteristic)
    {
        std::string rxValue = pCharacteristic->getValue();
        if (rxValue.length() > 0)
        {
            Serial.print("受信データ: ");
            for (int i = 0; i < rxValue.length(); i++)
            {
                Serial.print(rxValue[i]);
            }
            Serial.println();
        }
    }
};

void setup()
{
    Serial.begin(115200);
    Serial.println("ESP32 BLEサーバーを開始します...");

    // BLEデバイスの初期化
    BLEDevice::init(DEVICE_NAME);

    // BLEサーバーの作成
    pServer = BLEDevice::createServer();
    pServer->setCallbacks(new MyServerCallbacks());

    // BLEサービスの作成
    BLEService *pService = pServer->createService(SERVICE_UUID);

    // BLEキャラクタリスティックの作成
    pCharacteristic = pService->createCharacteristic(
        CHARACTERISTIC_UUID,
        BLECharacteristic::PROPERTY_READ |
        BLECharacteristic::PROPERTY_WRITE |
        BLECharacteristic::PROPERTY_NOTIFY |
        BLECharacteristic::PROPERTY_INDICATE);

    // キャラクタリスティックコールバックの設定
    pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());

    // BLE2902ディスクリプタの追加(Notify/Indicateに必要)
    pCharacteristic->addDescriptor(new BLE2902());

    // 初期値の設定
    uint8_t initialData[DATA_SIZE] = {0x00, 0x00, 0x00, 0x00};
    pCharacteristic->setValue(initialData, DATA_SIZE);

    // サービスの開始
    pService->start();

    // アドバタイジングの開始
    BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
    pAdvertising->addServiceUUID(SERVICE_UUID);
    pAdvertising->setScanResponse(false);
    pAdvertising->setMinPreferred(0x0);
    BLEDevice::startAdvertising();

    Serial.println("BLEサーバーが準備完了しました");
    Serial.println("デバイス名: " + String(DEVICE_NAME));
    Serial.println("サービスUUID: " + String(SERVICE_UUID));
    Serial.println("キャラクタリスティックUUID: " + String(CHARACTERISTIC_UUID));
    Serial.println("アドバタイジングを開始しました...");
}

void loop()
{
    // デバイス接続状態の確認
    if (deviceConnected)
    {
        // 定期的なデータ送信
        unsigned long currentTime = millis();
        if (currentTime - lastSendTime >= SEND_INTERVAL_MS)
        {
            // 送信データの準備(4バイトのカウンター値)
            uint8_t sendData[DATA_SIZE];
            sendData[0] = (dataCounter >> 24) & 0xFF;
            sendData[1] = (dataCounter >> 16) & 0xFF;
            sendData[2] = (dataCounter >> 8) & 0xFF;
            sendData[3] = dataCounter & 0xFF;

            // データの送信
            pCharacteristic->setValue(sendData, DATA_SIZE);
            pCharacteristic->notify();

            Serial.print("データ送信: ");
            Serial.print("カウンター=");
            Serial.print(dataCounter);
            Serial.print(" データ=");
            for (int i = 0; i < DATA_SIZE; i++)
            {
                Serial.print("0x");
                if (sendData[i] < 16)
                    Serial.print("0");
                Serial.print(sendData[i], HEX);
                Serial.print(" ");
            }
            Serial.println();

            dataCounter++;
            lastSendTime = currentTime;
        }
    }

    // 接続状態の変化を検出してアドバタイジングを再開
    if (!deviceConnected && oldDeviceConnected)
    {
        delay(500); // スタックのクリア待ち
        pServer->startAdvertising();
        Serial.println("アドバタイジングを再開しました");
        oldDeviceConnected = deviceConnected;
    }

    // 新しい接続の検出
    if (deviceConnected && !oldDeviceConnected)
    {
        oldDeviceConnected = deviceConnected;
        dataCounter = 0; // カウンターをリセット
    }

    delay(50); // CPU負荷軽減
}

nRF Connectで受信

スマートフォンにnRF Connectを導入しました。以下は確認までの手順を簡単に書いたものです。
なお、ESP32には上記ソースコードでFWの書き換えを行い、起動状態のままとしておきましょう。

  1. ここからインストール
  2. nRF Connectを起動し、ESP32_BLEで検索
    1000000799.png
  3. デバイスを発見後、Connect(接続に成功すると、ESP32_BLEのタブが新しく表示される)
    1000000800.png
  4. 表示されたサービスやUUIDから、ESP32のコード中に書いたものを探し、3つの下向きの矢印アイコンをクリックする(受信できていれば、受信時のデータが表示されるとともに、3つの下向きの矢印アイコンにバツ印がつく)
    1000000801.png

結果

nRF Connectで受信を確認できました。
これでフェーズ1は終了です。

フェーズ2:Windowsクライアント開発

ここでの目標は、nRF ConnectをWindows11上で動作するアプリに置き換えるだけにします。未知の分野に取り組むときは、既知をベースに漸進的に進めると、問題に遭遇したときの原因の切り分けが簡単な傾向があります。通信の場合、データを送る側と受ける側がいます。ここでは、送信側であるESP32の動作は変えず受信側を変える、として進めることとします。冒頭に例えると、未知はアプリケーション構築と受信で、既知はESP32で送信できたという事実です。アプリケーションだけでなくOSまで変わることが漸進的なのかという議論はあると思いますが…

Python + BLEAK

なるべく気軽に、かつ、ささっと試せるのが良いので、PythonでBLE通信ができる環境を作れないかを探したところ、BLEAKフレームワークを使う例が多数見つかったので、それらを参考に進めることとしました。
アプリケーションに求める要件としては、

  1. コマンドラインアプリである
  2. アプリ起動後、アドバタイジング中のBluetoothデバイスを検索し、ESP32_BLEの名称を持つデバイスを見つけたら接続する
  3. 接続成功後、Notify属性のUUIDを取得開始する
  4. 受信中は受信したデータを測定する動作を実行する。測定はいずれも1秒平均とし、受信データビット数、受信回数、受信時間間隔とする

としました。
これに合致するコードを以下に示します。

BLEAKを使ったBLE受信のサンプルコード
import asyncio
import threading
import time
import statistics
from typing import List, Optional
from datetime import datetime
from bleak import BleakClient, BleakScanner
from bleak.backends.characteristic import BleakGATTCharacteristic

class BLEDataMeasurement:
    def __init__(self):
        self.data_received: List[bytes] = []
        self.receive_times: List[float] = []
        self.receive_counts = 0
        self.total_bytes = 0
        self.start_time = None
        self.last_stats_time = None
        self.running = False
        
    def start_measurement(self):
        """測定開始"""
        self.start_time = time.time()
        self.last_stats_time = self.start_time
        self.running = True
        
    def add_data(self, data: bytes):
        """データ受信時の処理"""
        if not self.running:
            return
            
        current_time = time.time()
        self.data_received.append(data)
        self.receive_times.append(current_time)
        self.receive_counts += 1
        self.total_bytes += len(data)
        
        # 1秒ごとに統計を出力
        if current_time - self.last_stats_time >= 1.0:
            self.print_statistics()
            self.last_stats_time = current_time
            
    def print_statistics(self):
        """統計情報の出力"""
        if not self.receive_times:
            return
            
        current_time = time.time()
        elapsed_time = current_time - self.start_time
        
        # 1秒あたりのデータ受信量(bps)
        bps = (self.total_bytes * 8) / elapsed_time if elapsed_time > 0 else 0
        
        # 1秒あたりの受信回数
        rps = self.receive_counts / elapsed_time if elapsed_time > 0 else 0
        
        # 受信時間間隔の計算
        intervals = []
        if len(self.receive_times) > 1:
            for i in range(1, len(self.receive_times)):
                intervals.append(self.receive_times[i] - self.receive_times[i-1])
        
        # 平均値と分散値
        avg_interval = statistics.mean(intervals) if intervals else 0
        var_interval = statistics.variance(intervals) if len(intervals) > 1 else 0
        
        print(f"[統計] bps: {bps:.2f} | 受信回数/秒: {rps:.2f} | 間隔平均: {avg_interval:.4f}s | 間隔分散: {var_interval:.6f}")

class BLEApplication:
    def __init__(self, target_device_name: str):
        self.target_device_name = target_device_name
        self.client: Optional[BleakClient] = None
        self.measurement = BLEDataMeasurement()
        self.notification_char: Optional[BleakGATTCharacteristic] = None
        
    async def scan_and_connect(self) -> bool:
        """デバイススキャンと接続"""
        print(f"デバイス名 '{self.target_device_name}' を含むBLEデバイスをスキャン中...")
        
        try:
            # 特定のデバイス名を持つデバイスを検索
            device = await BleakScanner.find_device_by_filter(
                lambda d, ad: d.name and self.target_device_name in d.name,
                timeout=10.0
            )
            
            if not device:
                print(f"デバイス名に '{self.target_device_name}' を含むデバイスが見つかりませんでした。")
                return False
                
            print(f"デバイスが見つかりました: {device.name} ({device.address})")
            
            # デバイスに接続
            self.client = BleakClient(device.address)
            await self.client.connect()
            
            if self.client.is_connected:
                print(f"接続成功: {device.name}")
                return True
            else:
                print("接続に失敗しました。")
                return False
                
        except Exception as e:
            print(f"接続エラー: {e}")
            return False
    
    async def find_notify_characteristic(self) -> bool:
        """Notify属性を持つ特性を検索"""
        try:
            services = await self.client.get_services()
            
            for service in services:
                for char in service.characteristics:
                    if "notify" in char.properties:
                        print(f"Notify特性を発見: {char.uuid}")
                        self.notification_char = char
                        return True
                        
            print("Notify属性を持つ特性が見つかりませんでした。")
            return False
            
        except Exception as e:
            print(f"特性検索エラー: {e}")
            return False
    
    def notification_handler(self, sender, data):
        """通知データ受信ハンドラ"""
        self.measurement.add_data(data)
    
    async def start_notifications(self) -> bool:
        """通知の開始"""
        try:
            await self.client.start_notify(
                self.notification_char.uuid,
                self.notification_handler
            )
            print(f"データ受信を開始しました。UUID: {self.notification_char.uuid}")
            self.measurement.start_measurement()
            return True
            
        except Exception as e:
            print(f"通知開始エラー: {e}")
            return False
    
    async def stop_notifications(self):
        """通知の停止"""
        try:
            if self.notification_char:
                await self.client.stop_notify(self.notification_char.uuid)
                print("データ受信を停止しました。")
        except Exception as e:
            print(f"通知停止エラー: {e}")
    
    async def disconnect(self):
        """デバイス切断"""
        try:
            if self.client and self.client.is_connected:
                await self.client.disconnect()
                print("デバイスから切断しました。")
        except Exception as e:
            print(f"切断エラー: {e}")

def wait_for_keypress():
    """キーボード入力待ち(別スレッドで実行)"""
    input("何かキーを押すと切断します...")

async def main():
    # 対象のデバイス名を設定(部分一致)
    TARGET_DEVICE_NAME = "ESP32_BLE"
    
    app = BLEApplication(TARGET_DEVICE_NAME)
    
    try:
        # デバイススキャンと接続
        if not await app.scan_and_connect():
            return
        
        # Notify特性の検索
        if not await app.find_notify_characteristic():
            await app.disconnect()
            return
        
        # 通知開始
        if not await app.start_notifications():
            await app.disconnect()
            return
        
        # キーボード入力待ちを別スレッドで開始
        input_thread = threading.Thread(target=wait_for_keypress, daemon=True)
        input_thread.start()
        
        print("測定中... キーを押すと終了します。")
        
        # キーボード入力があるまで待機
        while input_thread.is_alive():
            await asyncio.sleep(0.1)
        
        # 通知停止と切断
        await app.stop_notifications()
        await app.disconnect()
        
        # 最終統計の表示
        app.measurement.running = False
        print("\n=== 最終統計 ===")
        app.measurement.print_statistics()
        
    except KeyboardInterrupt:
        print("\nプログラムが中断されました。")
        await app.stop_notifications()
        await app.disconnect()
    except Exception as e:
        print(f"エラーが発生しました: {e}")
        await app.disconnect()

if __name__ == "__main__":
    # BLEAKのインストールが必要: pip install bleak
    print("BLE接続・データ測定アプリケーション")
    asyncio.run(main())

importで多数のエラーが出る場合は、適宜pipsで導入してください。
なお、接続と通信が確認できればよいので、この段階ではボーレートや通信の切断処理については特に気にしないことにします。
では、動作させてみましょう。

結果

[統計] bps: 32.53 | 受信回数/秒: 1.02 | 間隔平均: 1.0000s | 間隔分散: 0.000052
BLEのコールバックと送信データ量は、送信側と同期が取れていることを確認できました。
データ量が少ないためか、特にトラブルは発生していないようです。

フェーズ3:ESP32 + Windowsでの通信ボトルネック把握

さて、ESP32とWindowsとでBLE通信を行う場合、通信のボトルネックはどちらにあるでしょうか。この場合、すべての面においてリソースが少ないESP32側と想定するのが普通でしょう。前のフェーズで実装した、1通信あたりのデータ量と通信間隔に着目してボトルネックを明らかにしたいと思います。

送信データ量を徐々に増やしてみた結果

Windows側は問題なく受信できる想定です。そこで

#define DATA_SIZE 4           // 送信データサイズ(バイト)

送信データサイズを512バイトまで増やしてみることにします。特に基準を持ち合わせてはいないため、送信バイト数が増えるほど送受信側のどちらかに処理性能が維持できなくなる境界があるのでは?と考え、512に漸近する形となるよう、次の範囲としてみました。

[0, 256), [256, 384), [384, 448), [448, 480), [480, 496), [496, 504), [504, 508), [508, 510), [510, 511), [511, 512)

以下のようになりました。
横軸は時間、縦軸は実質の受信ボーレート、線は送信バイト数で色分け(色ごとのバイト数は右にある凡例参照)としました。

scatter2d_aligned.png

ここから読み取れることとして

  • いずれの送信バイト数においても、通信開始直後は送信バイト数より高い受信ボーレートとなっており、徐々にある値に収束していく動きが観測できた
  • このような結果になる理由が突き止めらなかったものの、収束した領域に限定すれば送信バイト数に比例して受信ボーレートが変化する傾向があることは見て取れる
  • 512バイト付近の数本のデータがクロスしているように見える

このフェーズとしては十分な結果と言える、と考えています。

送信間隔を徐々に早くしてみた結果

次に、送信間隔を徐々に早くしてみます。これもWindows側は問題なく受信できる想定です。ESP32側の送信間隔を徐々に短くしていけば、ESP32側がどこかでその間隔を維持できなくなる点があると予想されます。そこで

#define SEND_INTERVAL_MS 1000 // 送信間隔(ミリ秒)
#define DATA_SIZE 4           // 送信データサイズ(バイト)

の送信間隔を徐々に短くして、ボトルネックを探すことにします。
サイズは512バイト固定で進めてみます。

scatter2d_aligned.png

(↑凡例の単位はサイズではなくミリ秒です)

ここから読み取れることとして

  • 送信間隔37.5msecまでは想定通りの受信ボーレートがほぼ変動なく推移している
  • 20msec、15msec、7.5msecは測定開始直後こそ通信速度は間隔に準じているものの、時間の経過とともに、ある値に収束するかのような傾向がみられる
    * どの送信間隔においても、ESP32に処理の遅れは見られなかった

と、まさかの結果になりました。どうやらWindows側にボトルネックがあるようです。

フェーズ4:接続パラメータ最適化

さすがに上記の結果はいまいちすぎるだろうと、マイクロソフトの公式ドキュメントをくまなく見たところ、ここに通信速度を向上させるオプションが有ることがわかりました。用途に注釈がついていて、機器のFWアップデートで使うケースを想定しているようです。FWアップデートということはデータの方向としては、WindowsからESP32側が想定されるので、その想定とは逆方向ですが試してみなければわかりません。

スループット指定のサンプルコード

class HighSpeedBleakClient(BleakClientWinRT):
	def **init**(self, address, **kwargs):
	# WinRT固有の設定を追加
		kwargs.update({
			'winrt': {
			'use_cached_services': False
			}
		})
		super().**init**(address, **kwargs)

	async def _configure_connection(self):
		"""WinRT APIを直接使用した接続パラメータ設定"""
		device_native = cast(BluetoothLEDevice, self._requester)
		params = bt.BluetoothLEPreferredConnectionParameters device_native.request_preferred_connection_parameters(params.throughput_optimized)

	async def connect(self, **kwargs) -> bool:
		"""接続処理をオーバーライド"""
		# print(f"connect: {kwargs}")
		result = await super().connect(**kwargs)
		if result:
			await self._configure_connection()
		return result

注意:
WinRTを使う必要があります。
また、若干のインターフェイスの違いから、接続メソッドのパラメータが違いますが、省略しています。

結果

フェーズ3と同様の手順で測定した結果です。

scatter2d_aligned.png

ここから読み取れることとして

  • 送信間隔20msecまではほぼ想定通りの受信ボーレートで変動も少なくなく推移している
  • 15msecはまだ良い傾向にあるものの、7.5msecは測定開始直後の変動が激しく時間の経過とともに徐々にある値に収束するかのような傾向がみられる

フェーズ3との比較で、20msが顕著に改善した結果となりました。15msもだいぶ改善が見られたものの、安定した通信状況の点からはもう一歩です。
また、ESP32からWindows方向へのデータ送信に対して、通信速度を向上させるオプション指定が有効であることも確認できました。
さて、最後に20msでの通信速度を計算しましょう。

512バイト*1/0.02秒=25600バイト/秒、204800bps

が安定して出せる通信速度となりました。

おわりに

今回の検討で、BLE接続から高速化までを実現できました。Windows側のボトルネック解消には、非常にわかりくい方法で速度指定するコードを書く必要があり、苦労しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?