【ESP32 × Flutter】Wi-Fiのセキュリティと信号強度に応じたアイコン表示とESP32接続

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

はじめに

前回の改修では、Wi-Fi接続を、アプリ画面でSSIDを直接入力するのではなく、利用可能なWi-Fiネットワークをリスト表示して選択できるようにするところまでを行った。

今回は下記を行う

  • Wi-Fiネットワークリストに、セキュリティの有無と信号強度に応じたアイコン表示
  • 使用したいWi-Fiリストが見つからないときに再度探すボタン+注釈文言追加
  • SSID選択とパスワード入力画面の分離

Wi-Fiネットワーク選択画面にセキュリティの有無を表示

Wi-Fiのセキュリティと信号強度をESP32から取得

Wi-Fiの信号強度(RSSI:Received Signal Strength Indicator)とセキュリティ情報は、ESP32でWi-Fiネットワークをスキャンする際に取得できる。

Wi-Fiネットワークの情報を保持する構造体を用意して、スキャンされたWi-FiネットワークのリストをNetworkInfo構造体のベクタとして返すように修正する。

C++
// WiFiConnection.h
#pragma once

#include <WiFi.h>
#include <vector>

// Wi-Fiネットワークの情報を保持する構造体
struct NetworkInfo {
  String ssid;
  int32_t rssi;
  uint8_t encryptionType;
};

bool setupWiFi(const char *ssid, const char *password);
std::vector<String> scanNetworks();
C++
// WiFiConnection.cpp
#include "WiFiConnection.h"

std::vector<NetworkInfo> scanNetworks() {
  std::vector<NetworkInfo> networkList;

  WiFi.mode(WIFI_STA);
  WiFi.disconnect();
  delay(100); // モード変更後に少し待つ

  Serial.println("Scanning WiFi networks...");
  int n = WiFi.scanNetworks();
  Serial.println("Scan complete.");
  if (n == 0) {
    Serial.println("No networks found.");
  } else {
    Serial.println("Available networks:");
    Serial.println("SSID / RSSI / Encryption");
    for (int i = 0; i < n; ++i) {
      // スキャンされた各ネットワークの情報を取得
      NetworkInfo info;
      info.ssid = WiFi.SSID(i);
      info.rssi = WiFi.RSSI(i);
      info.encryptionType = WiFi.encryptionType(i);

      // ネットワーク情報をシリアル出力
      Serial.print(info.ssid);
      Serial.print(" / ");
      Serial.print(info.rssi);
      Serial.print(" / ");
      switch (info.encryptionType) {
        case WIFI_AUTH_OPEN:
          Serial.println("Open");
          break;
        case WIFI_AUTH_WEP:
          Serial.println("WEP");
          break;
        case WIFI_AUTH_WPA_PSK:
          Serial.println("WPA_PSK");
          break;
        case WIFI_AUTH_WPA2_PSK:
          Serial.println("WPA2_PSK");
          break;
        case WIFI_AUTH_WPA_WPA2_PSK:
          Serial.println("WPA_WPA2_PSK");
          break;
        case WIFI_AUTH_WPA2_ENTERPRISE:
          Serial.println("WPA2_ENTERPRISE");
          break;
        // その他の暗号化方式については適宜追加
        default:
          Serial.println("Other");
      }

      networkList.push_back(info);
    }
  }
  return networkList;
}

Wi-Fiリスト、暗号化方式、および信号強度をシリアルポートに出力して確認する。

次にBLEManager.cpp にある serializeNetworks 関数と onWrite コールバック関数も、新しい NetworkInfo 構造体を扱えるように変更する。

C++
// 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;
}

アプリのBLEServiceクラスのrequestWifiList メソッドも修正。requestWifiList 関数内でESP32から送られてくるWi-Fiリストを受け取り、それを解析してリストに変換する。

Dart
// ble_service.dart

import 'dart:convert';
import 'dart:async';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';

// ... その他のimport文 ...

class BLEService {
  // ... 既存のコード ...

  Future<List<WifiNetwork>> requestWifiList(BluetoothDevice device) async {
    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.write(utf8.encode("request_wifi_list"));

      List<int> response = await characteristic.read().timeout(
        Duration(seconds: timeoutSeconds),
        onTimeout: () {
          throw TimeoutException(
              'Wi-Fi list not received within the timeout period');
        },
      );

      String responseStr = utf8.decode(response);
      final jsonResponse = jsonDecode(responseStr) as List<dynamic>;

      List<WifiNetwork> wifiList = jsonResponse.map((networkJson) {
        final networkMap = networkJson as Map<String, dynamic>;
        return WifiNetwork.fromJson(networkMap);
      }).toList();

      return wifiList;
    } catch (e) {
      print('Error occurred while requesting Wi-Fi list: $e');
      rethrow;
    } finally {
      await device.disconnect();
    }
  }
}

// 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
    );
  }
}

アプリで、BLE接続後に表示される画面(BluetoothConnectionCompleteScreen)の_requestWifiListメソッドも改修し、ネットワーク設定に進むボタンを押したときに、利用可能なネットワークリストを暗号方式、信号強度とともにコンソールに出力できるようにする。

Dart
// bluetooth_connection_complete_screen.dart
void _requestWifiList() async {
    try {
      // Wi-Fiネットワークリストを要求する
      List<WifiNetwork> wifiList =
          await bleService.requestWifiList(widget.device);

      // コンソールにWi-Fiリストを表示(SSID、RSSI、暗号化タイプ)
      for (var wifi in wifiList) {
        print(
            'SSID: ${wifi.ssid}, RSSI: ${wifi.rssi}, Encryption: ${wifi.encryptionType}');
      }

      // 取得したWi-Fiリストを次の画面に渡してナビゲートする
      Navigator.pushReplacement(
        context,
        MaterialPageRoute(
          builder: (context) =>
              WifiConnectionScreen(device: widget.device, wifiList: wifiList),
        ),
      );
    } catch (e) {
      // エラー処理を行う
      print(e);
    }
  }

ESP32のWi-Fi暗号化方式の番号(列挙型)

ESP32のWi-Fi暗号化方式の番号は、通常は整数(int型)で扱われる。Arduino ESP32ライブラリにおいて、これらの暗号化方式はwifi_auth_mode_tという列挙型で定義されており、それぞれの暗号化方式には特定の整数値が割り当てられている。

espressif公式参照(v5.1.2)

Wi-Fi - ESP32 - — ESP-IDF Programming Guide v5.1.2 documentation
C++
typedef enum {
    WIFI_AUTH_OPEN = 0,         /**< authenticate mode : open */
    WIFI_AUTH_WEP,              /**< authenticate mode : WEP */
    WIFI_AUTH_WPA_PSK,          /**< authenticate mode : WPA_PSK */
    WIFI_AUTH_WPA2_PSK,         /**< authenticate mode : WPA2_PSK */
    WIFI_AUTH_WPA_WPA2_PSK,     /**< authenticate mode : WPA_WPA2_PSK */
    WIFI_AUTH_WPA2_ENTERPRISE,  /**< authenticate mode : WPA2_ENTERPRISE */
    WIFI_AUTH_WPA3_PSK,         /**< authenticate mode : WPA3_PSK */
    WIFI_AUTH_WPA2_WPA3_PSK,    /**< authenticate mode : WPA2_WPA3_PSK */
    WIFI_AUTH_WAPI_PSK,         /**< authenticate mode : WAPI_PSK */
    WIFI_AUTH_MAX
} wifi_auth_mode_t;

なので、先ほどのflutterアプリのコンソールに出力した結果

C++
flutter: SSID: aterm-2221d6a, RSSI: -62, Encryption: 3
flutter: SSID: Buffalo-G-F0BA, RSSI: -62, Encryption: 3

ここでの Encryption: 3WIFI_AUTH_WPA2_PSK を意味する。これにより、対応するネットワークがWPA2-PSK方式で暗号化されていることを示している。

Wi-Fiのセキュリティをアプリに表示

今回は、下記のルールに基づいて鍵アイコンの表示非表示を行う

  • WIFI_AUTH_OPEN (0):オープンネットワークで、セキュリティは担保されていないので、鍵アイコンを表示しない。
  • WIFI_AUTH_WEP (1):WEP暗号化があるのでセキュリティが担保されているが、古いタイプなので安全性は低い。一応鍵アイコンを表示。
  • WIFI_AUTH_WPA_PSK (2), WIFI_AUTH_WPA2_PSK (3),WIFI_AUTH_WPA_WPA2_PSK (4), WIFI_AUTH_WPA2_ENTERPRISE (5), WIFI_AUTH_WPA3_PSK (6), WIFI_AUTH_WPA2_WPA3_PSK (7), WIFI_AUTH_WAPI_PSK (8):セキュリティが担保されているので鍵アイコンを表示
  • 上記以外の暗号化方式が “unknown” として表示された場合、セキュリティの状態が不明であるため、通常は鍵アイコンを表示しない。
Dart
// wifi_connection_screen.dart
class _WifiConnectionScreenState extends ConsumerState<WifiConnectionScreen> {

  @override
  Widget build(BuildContext context) {
    return BaseContainer(
      isLoading: isLoading,
      children: <Widget>[
        Expanded(
          child: ListView.separated(
            itemCount: widget.wifiList.length,
            separatorBuilder: (context, index) => Divider(height: 1),
            itemBuilder: (context, index) {
              WifiNetwork network = widget.wifiList[index];
              IconData? lockIcon;
              switch (network.encryptionType) {
                case 0: // WIFI_AUTH_OPEN
                  // オープンネットワークのため鍵アイコンは表示しない
                  lockIcon = null;
                  break;
                case 1: // WIFI_AUTH_WEP
                case 2: // WIFI_AUTH_WPA_PSK
                case 3: // WIFI_AUTH_WPA2_PSK
                case 4: // WIFI_AUTH_WPA_WPA2_PSK
                case 5: // WIFI_AUTH_WPA2_ENTERPRISE
                case 6: // WIFI_AUTH_WPA3_PSK
                case 7: // WIFI_AUTH_WPA2_WPA3_PSK
                case 8: // WIFI_AUTH_WAPI_PSK
                  lockIcon = Icons.lock;
                  break;
                default:
                  lockIcon = null;
              }
              return ListTile(
                title: Text(network.ssid),
                trailing: Icon(lockIcon),
                
                onTap: () {
                  setState(() {
                    selectedSSID = network.ssid;
                  });
                  // パスワード入力画面に遷移
                  navigateToPasswordInputScreen(selectedSSID!);
                },
                selected: selectedSSID == widget.wifiList[index],
              );
            },
          ),
        ),
      ],
    );
  }

無事、鍵アイコンが表示された。

Wi-Fiネットワーク選択画面にセキュリティの有無を表示

Wi-Fiの信号強度とバーの数値(0-4)とは?

Wi-Fiの信号強度は、アクセスポイントからの信号がどれだけ強いかを示しす。一般的には、RSSI(Received Signal Strength Indicator、受信信号強度指標)として表され、デシベル単位で測定される。RSSIの値は通常、負の数値で表され、0に近いほど信号が強いことを意味する。例えば、-30dBmは非常に強い信号を示し、-90dBmは非常に弱い信号を示す。

Wi-Fiバーの数値(0-4)は、このRSSIの値をユーザーにわかりやすい形で示すためのもの。具体的なバーの数とRSSIの関係はデバイスやOSによって異なることがあるすが、一般的な目安は以下の通り。

  • 0バー: 非常に弱い信号または接続なし。RSSIが-90dBm以下の場合。
  • 1バー: 弱い信号。RSSIが約-80dBmから-90dBmの範囲。
  • 2バー: 中程度の信号。RSSIが約-70dBmから-80dBmの範囲。
  • 3バー: 強い信号。RSSIが約-60dBmから-70dBmの範囲。
  • 4バー: 非常に強い信号。RSSIが-60dBm以上。

Wi-Fiの信号強度をアプリに表示

FlutterのIconクラスでは、信号強度が良い場合と悪い場合のアイコンは標準装備されているが、細かく4段階で表示するようなアイコンは現状なさそう。

Icons class - material library - Dart API
API docs for the Icons class from the material library, for the Dart programming language.

できれば、このように4段階で表示したい。

Wi-Fi強度を表示する4段階のカスタムアイコンを表示

Google FontsにはWi-FI強度表示のアイコンが4段階に分かれて記載あったので、こちらを使うことにする。

必要なアイコンを.svg形式でダウンロード。

Material Symbols and Icons - Google Fonts
Material Symbols are our newest icons consolidating over 2,500 glyphs in a single font file with a wide range of design ...

flutter_svg パッケージを使用

pubspec.yaml ファイルに flutter_svg パッケージを追加し、SVGファイルを直接利用できるようにする。

flutter_svg | Flutter package
An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files...

SVGファイルをプロジェクトに追加:

SVGファイルをプロジェクトの assets/images ディレクトリに配置。

pubspec.yamlにアセットとしてSVGファイルを追加

Wi-Fiの信号強度(rssi)に基づいて、異なるWi-Fiアイコンを表示

Dart
// wifi_connection_screen.dart
import 'package:flutter_svg/flutter_svg.dart';

class WifiConnectionScreen extends ConsumerStatefulWidget {
  final BluetoothDevice device;
  final List<WifiNetwork> wifiList;

  WifiConnectionScreen({required this.device, required this.wifiList});

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

class _WifiConnectionScreenState extends ConsumerState<WifiConnectionScreen> {
  String? selectedSSID;
  bool isLoading = false;

  // RSSI値に基づいて適切なWi-Fiアイコンを取得する関数
  String getWifiIconPath(int rssi) {
    if (rssi >= -60) {
      return 'assets/images/signal_wifi_4_bar.svg'; // 最強信号
    } else if (rssi >= -70) {
      return 'assets/images/signal_wifi_3_bar.svg'; // 強信号
    } else if (rssi >= -80) {
      return 'assets/images/signal_wifi_2_bar.svg'; // 中信号
    } else if (rssi >= -90) {
      return 'assets/images/signal_wifi_1_bar.svg'; // 弱信号
    } else {
      return 'assets/images/signal_wifi_0_bar.svg'; // とても弱信号または接続なし
    }
  }

  @override
  Widget build(BuildContext context) {
    return BaseContainer(
      isLoading: isLoading,
      children: <Widget>[
        HeaderText('ネットワーク'),
        Spacing.h36(),
        BodyText('Wi-Fiネットワークを選択してください'),
        Spacing.h16(),
        Expanded(
          child: ListView.separated(
            itemCount: widget.wifiList.length,
            separatorBuilder: (context, index) => Divider(height: 1),
            itemBuilder: (context, index) {
              WifiNetwork network = widget.wifiList[index];
              IconData? lockIcon;
              String wifiIconPath = getWifiIconPath(network.rssi);
              switch (network.encryptionType) {
                case 0: // WIFI_AUTH_OPEN
                  // オープンネットワークのため鍵アイコンは表示しない
                  lockIcon = null;
                  break;
                case 1: // WIFI_AUTH_WEP
                case 2: // WIFI_AUTH_WPA_PSK
                case 3: // WIFI_AUTH_WPA2_PSK
                case 4: // WIFI_AUTH_WPA_WPA2_PSK
                case 5: // WIFI_AUTH_WPA2_ENTERPRISE
                case 6: // WIFI_AUTH_WPA3_PSK
                case 7: // WIFI_AUTH_WPA2_WPA3_PSK
                case 8: // WIFI_AUTH_WAPI_PSK
                  lockIcon = Icons.lock;
                  break;
                default:
                  lockIcon = null;
              }
              return ListTile(
                title: Text(network.ssid),
                trailing: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    if (lockIcon != null) Icon(lockIcon), // 鍵アイコンを表示
                    SizedBox(width: 8), // アイコン間のスペース
                    SvgPicture.asset(wifiIconPath,
                        width: 24, height: 24), // Wi-Fi信号強度アイコンを表示
                  ],
                ),
                onTap: () {
                  setState(() {
                    selectedSSID = network.ssid;
                  });
                  // パスワード入力画面に遷移
                  navigateToPasswordInputScreen(selectedSSID!);
                },
                selected: selectedSSID == widget.wifiList[index],
              );
            },
          ),
        ),
      ],
    );
  }
}

下記のように無事表示された。

アイコンの背景色が黒なのが違和感あるが、一旦後回しにしおう。

Wi-Fiネットワーク表示までの待ち時間対策

今のままだと、ネットワーク接続へ進むボタンを押してから、利用可能なWi-Fiリストを表示するまでの間、何も表示されないので少し待ち時間が長い。

対策として、ローディングアニメーションを表示して、かつ、「利用可能なWi-Fiを探しています…」という文言を下に表示する。

Dart
// bluetooth_connection_complete_screen.dart
class BluetoothConnectionCompleteScreen extends StatefulWidget {
  final BluetoothDevice device;
  BluetoothConnectionCompleteScreen({required this.device});

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

class _BluetoothConnectionCompleteScreenState
    extends State<BluetoothConnectionCompleteScreen> {
  final bleService = BLEService();
  bool isSearching = false;

  // ... 省略 ...

  void _requestWifiList() async {
    setState(() {
      isSearching = true;
    });
    // Wi-Fiネットワークリストを要求する処理
    // ... 省略 ...
  }

  @override
  Widget build(BuildContext context) {
    return BaseContainer(
      children: <Widget>[
        HeaderText("あなたの$productNameと接続しました"),
        Spacing.h24(),
        BodyText("引き続き$productNameをセットアップします"),
        Spacing.h36(),
        AppButton(
          onPressed: isSearching ? null : _requestWifiList,
          text: 'ネットワーク接続へ進む',
          enabled: !isSearching,
        ),
        // 新しいテキストウィジェットを追加
        if (isSearching)
          Column(
            children: [
              Spacing.h24(),
              Text('利用可能なWi-Fiネットワークを探しています...',
                  style: TextStyle(
                    color: Colors.blue,
                    fontSize: 16.0, // フォントサイズは適宜調整してください
                  )),
              Spacing.h24(),
              CircularProgressIndicator(), // ローディングインジケーターも表示
            ],
          ),
      ],
    );
  }
}

Wifiネットワークスキャンが完了すると同時にBLE通信が切れる問題

と、ここで、Wifi networkのリスト表示をした途端にBLE通信が切れてしまい、wifi接続を確立できないエラーが発生。

他にエラーログが出ないので特定に少し時間がかかったが、今回の変更でSSIDリストだけではなく、信号強度とセキュリティ値も一緒にアプリに送ることにしたので、大量のデータを送信しようとして、接続が切断されたのではと推測。

BLEのMTU(Maximum Transmission Unit)をチェックし、デバイスが許容できる最大サイズを超えていないかをチェック

C++
void BLEManager::CharacteristicCallbacks::onWrite(BLECharacteristic *pCharacteristic) {
  // ... (省略) ...

  if (value == "request_wifi_list") {
    std::vector<NetworkInfo> networks = scanNetworks();
    String networkListJson = serializeNetworks(networks);

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

    if(networkListJson.length() > 20) {
      // ここでMTUを超えていることを警告
      Serial.println("Warning: Data size exceeds the default MTU size of 20 bytes");
      // 必要に応じてデータの分割や他の処理を行う
    }

    <a href="https://www.notion.so/f4d117b1bd9c4c568282fef69a87b205">// データをBLE経由で送信
    pCharacteristic->setValue(networkListJson.c_str());
    pCharacteristic->notify();
    Serial.println("WiFi network list sent via BLE");
    return;</a>
  }

  // ... (省略) ...
}

MTUサイズ問題かと思い対応したが、、

データをMTUサイズに収まるように分割し、複数のメッセージとして順番に送信する必要がある。もしくは、MTUサイズをデバイスからリクエストして、デフォルトの23Byteからあげる。

flutter_blue_plusでは、Androidのみ明示的にMTUサイズを変更できる。iOSはできない

C++
On Android, we request an mtu of 512 by default during connection (see: connect function arguments).
On iOS & macOS, the mtu is negotiated automatically, typically 135 to 255.

https://pub.dev/packages/flutter_blue_plus

通信パケットのサイズがMTUを超えている可能性を考え、MTUサイズの出力(517byte)と、送信情報をwifiのssidのみに変更(34byteなど)にしたが、BLE切断されたので、他に原因がありそう。

と、ここにきて詰まってしまったので、現状コードをあげて一緒に開発しているエンジニアに聞いたところ

ちょっと今のところまだ原因がわかってないのですが、別の解決策としてWiFiのスキャンをモバイルアプリ側で実施するのはいかがでしょうか?
たぶんesp32でスキャンして渡すよりも処理が簡単になるような気がしており。

方針転換→アプリ側でWi-Fiスキャン実施

確かに、、なぜESP32でWi-FiネットワークをスキャンしてBLE通信経由でアプリに表示するなんて面倒なことをやっていたのだろう?と。バカすぎた。。

flutterでwifi_scanパッケージがあることを発見。こちらでの実装に切り替える。

wifi_scan | Flutter package
Flutter plugin to scan for nearby visible WiFi access points.

と思ったのだが、パッケージに、「No public API, requires special entitlements from Apple」と記載されており、iOSではアプリからWi-Fiスキャンはできない(AppleがAPIを提供していない)とのこと。なんと。。Androidはできるが。

実際にexampleコードを入れてみてWi-Fiスキャン機能を試してみたら「Cannot get scanned results: CanGetScannedResults.notSupported」というエラーが表示された。ちなみに、最初にiosでwi-fiスキャンできるものだと早合点してしまい(正確にはパッケージのiOSの必要条件を読んでいなかった。読んだが正確に理解せずにスルーしてしまった)、exampleコードを実装(ここでも環境が違い少し躓く)した挙句に表示されないとなって、初めてdocumentを読み込むという遠回りなことをしてしまったので、反省。

現状のBLEを使用したwifi scan機能をもう少し進めてみるか。

原因判明:アプリ側からWi-Fiスキャンが正常に完了した時もBLE切断要求していた orz

flutter側でWi-FiスキャンをESP32に対して要求して、レスポンスを処理する関数部分で、正常動作した時(finally)もawait device.disconnect(); とBLE通信切断を要求していたことが原因だった。

ESP32側のBluetooth通信のパケットサイズや、Bluetooth通信とWi-Fiスキャンを同時処理することなどは特に問題はなかった。

C++
Future<List<String>> requestWifiList(BluetoothDevice device) async {
    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);

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

      // ESP32 からの応答を待つ
      List<int> response = await characteristic.read().timeout(
        Duration(seconds: timeoutSeconds),
        onTimeout: () {
          throw TimeoutException(
              'Wi-Fi list not received within the timeout period');
        },
      );

      print('response:$response');

      // 応答をデコードしてリストに変換
      String responseStr = utf8.decode(response);
      final json = jsonDecode(responseStr);
      List<String> wifiList =
          List<String>.from(json.map((model) => model.toString()));
      return wifiList;
      
    } catch (e) {
      print('Error occurred while requesting Wi-Fi list: $e');
      rethrow;
    } finally {
      await device.disconnect();
    }
  }

既存コードで、ユーザーが入力したSSIDとパスワードをもとに、ESP32へWi-FI接続を要求する関数が同じファイルのすぐ下にあったのだが、これを何も考えずにコピペしてしまっていたことが原因。ちなみに、こちらの場合は、Wi-Fi接続が完了すればBluetooth接続は不要になる(その後のアプリとの接続やファイルダウンロードなどの処理はWi-Fiを用いて行うので)ので、こちらは正しい。

C++
Future<DeviceInfo> sendWifiInfo(
      BluetoothDevice device, String ssid, String password) async {
    // WiFi情報を結合
    final combined = '$ssid:$password';

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

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

      // WiFi情報を書き込む
      await characteristic.write(utf8.encode(combined));

      // 書き込みが成功した後、ESP32からの完了通知を受け取る
      List<int> response = await characteristic.read().timeout(
        const Duration(seconds: 15),
        onTimeout: () {
          throw TimeoutException('Response not received within 15 seconds');
        },
      );

      // BLE情報を受け取る
      String responseStr = utf8.decode(response);
      final bleResponse = BLEResponse.fromJson(jsonDecode(responseStr));
      if (bleResponse.status != 'ok') {
        throw Exception(bleResponse.message);
      }
      if (bleResponse.device == null) {
        throw Exception('Device info is null');
      } else {
        return bleResponse.device!;
      }
    } finally {
      await device.disconnect();
    }
  }
}

盲目的にコピペしてしまったことと、うまくいかない原因をてっきりBLE通信部分だと視点を間違えていたことが大きい。反省点が多い。。。

パスワード入力画面を分離

気を取り直して、今回の改修でWi-FiのSSIDを選ぶ画面と、その後のパスワードを入力する画面を分離することにしたので、パスワード入力画面を作成する。

Dart
// ignore_for_file: use_build_context_synchronously

import 'dart:async';
import 'package:clocky_app/api/user.dart';
import 'package:clocky_app/api/user_notifier.dart';
import 'package:clocky_app/models/device_info.dart';
import 'package:clocky_app/screens/setup/wifi_network_complete_screen.dart';
import 'package:clocky_app/services/clocky_service.dart';
import 'package:clocky_app/services/ble_service.dart';
import 'package:clocky_app/widgets/texts.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:clocky_app/widgets/base_container.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:clocky_app/widgets/spacing.dart';
import 'package:flutter/material.dart';

import '../../widgets/buttons.dart';
import '../../widgets/text_fields.dart';

class WifiPasswordInputScreen extends ConsumerStatefulWidget {
  final String ssid;
  final BluetoothDevice device;
  WifiPasswordInputScreen({required this.ssid, required this.device});
  @override
  _WifiPasswordInputScreenState createState() =>
      _WifiPasswordInputScreenState();
}

class _WifiPasswordInputScreenState
    extends ConsumerState<WifiPasswordInputScreen> {
  TextEditingController passwordController = TextEditingController();
  bool isLoading = false;
  ClockyService clockyService = ClockyService();
  BLEService bleService = BLEService();

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

  // Wi-Fi情報送信処理
  Future<void> sendWifiInfo(String ssid, String password) async {
    final userNotifier = ref.read(userProvider.notifier);
    print("sendWifiInfo called with SSID: $ssid and Password: $password");

    setState(() {
      isLoading = true;
    });

    try {
      print("Attempting to send Wi-Fi info via BLE...");
      DeviceInfo deviceInfo =
          await bleService.sendWifiInfo(widget.device, ssid, password);

      // バックエンドにデバイスIDを登録
      await userNotifier.updateUser(User(
        deviceId: deviceInfo.id,
      ));

      // 接続完了
      clockyService.setConnectedStatus(true);
      Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) => WifiNetworkCompleteScreen(),
        ),
      );
    } catch (e) {
      print("Error in sendWifiInfo: $e");
      if (e is TimeoutException) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text("Connection timed out"),
          ),
        );
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(e.toString()),
          ),
        );
      }
    } finally {
      setState(() {
        isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return BaseContainer(
      isLoading: isLoading,
      children: <Widget>[
        HeaderText('"${widget.ssid}"のパスワードを入力してください'),
        Spacing.h16(),
        AppTextField(
          hint: 'パスワード',
          controller: passwordController,
          obscureText: true,
        ),
        Spacing.h16(),
        AppButton(
          onPressed: () async {
            if (passwordController.text.isNotEmpty) {
              await sendWifiInfo(widget.ssid, passwordController.text);
            } else {
              // パスワードが入力されていない場合の処理
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(
                  content: Text("パスワードを入力してください"),
                ),
              );
            }
          },
          text: '接続する',
        ),
        Spacing.h36(),
        Column(
          crossAxisAlignment: CrossAxisAlignment.start, // 左揃え
          children: <Widget>[
            Center(
              child: Text(
                'パスワードがわからない場合',
                style: TextStyle(
                  fontSize: 18.0,
                  color: Colors.grey[850],
                ),
                textAlign: TextAlign.center,
              ),
            ),
            Spacing.h16(),
            Text(
              'Wi-Fiルーターに「パスワード」「暗号化キー」「WEP」「WEPキー」「PSK」「PSK AES」などの名称で記載されていることがあります。\n'
              '詳しくはWi-Fiルーター発売元にお問い合わせください。',
              style: TextStyle(
                fontSize: 16.0,
                color: Colors.grey[700],
              ),
              textAlign: TextAlign.left,
            ),
          ],
        ),
        Spacing.h16(),
      ],
    );
  }
}

これで、下記のようにパスワード入力画面を作成できた。

SSIDとパスワードによるWi-Fi接続処理

ESP32側のコード。

ESP32のWiFiライブラリを使っている場合、直接的に「パスワードが間違っている」という状態を判定するAPIは用意されていない。ただし、一般的には、接続試行が失敗した場合(WL_CONNECTED にならない場合)、その原因がパスワードの不一致である可能性が高い。

接続試行が失敗した場合に、エラーレスポンスをBLE経由でFlutterアプリに送信するコードを追加することで、Flutterアプリ側で適切なエラーメッセージを表示させる。

C++

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

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

    // SSIDとパスワードによるWi-Fi接続処理
    size_t delimiterPosition = value.find(':');
    if (delimiterPosition != std::string::npos) {
      ssid = value.substr(0, delimiterPosition);
      password = value.substr(delimiterPosition + 1);
    } else {
      const auto bleResponse = BLEResponse{"error", "invalid format"};
      const auto errorResponse = serializeBLEResponse(bleResponse);
      pCharacteristic->setValue(errorResponse.c_str());
      pCharacteristic->notify();
      return;
    }

    // FIXME: Remove
    Serial.printf("SSID: %s\n", ssid.c_str());
    Serial.printf("Password: %s\n", password.c_str());

    // Try to connect to the wifi with the given ssid and password
    WiFi.begin(ssid.c_str(), password.c_str());

    // Wait for connection
    unsigned long startTime = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - startTime < 5000) {
      delay(500);
      Serial.println("Connecting to WiFi...");
    }

    if (WiFi.status() == WL_CONNECTED) {
      // Return "ok" as a response if the SSID and password are saved correctly.
      Serial.println("Connected to the WiFi network");
      BLEResponse bleResponse{"ok", "connected", parent.deviceInfo};
      const auto okResponse = serializeBLEResponse(bleResponse);
      pCharacteristic->setValue(okResponse.c_str());
      pCharacteristic->notify();
      Serial.println("notify done");
      parent.requestDisconnect();
    } else {
      Serial.println("Failed to connect to the WiFi network");
      // 接続試行が失敗した原因がパスワードの不一致である可能性が高いため、
      // パスワードエラーのレスポンスを送信する
      BLEResponse bleResponse{"error", "password_incorrect"};
      const auto errorResponse = serializeBLEResponse(bleResponse);
      pCharacteristic->setValue(errorResponse.c_str());
      pCharacteristic->notify();
    }
  }
}

アプリ(Flutter)側のコード

BLEResponse オブジェクトの status プロパティをチェックし、それに基づいて処理を分岐する。status"ok" であれば接続成功と判断し、"password_incorrect" であればパスワードが間違っていることを示し、それ以外の場合にはその他のエラーとして処理する。

C++
// BLEManager.cpp
void BLEManager::CharacteristicCallbacks::onWrite(
    BLECharacteristic *pCharacteristic) {
  std::string value = pCharacteristic->getValue();

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

   

    // SSIDとパスワードによるWi-Fi接続処理
    size_t delimiterPosition = value.find(':');
    if (delimiterPosition != std::string::npos) {
      ssid = value.substr(0, delimiterPosition);
      password = value.substr(delimiterPosition + 1);
    } else {
      const auto bleResponse = BLEResponse{"error", "invalid format"};
      const auto errorResponse = serializeBLEResponse(bleResponse);
      pCharacteristic->setValue(errorResponse.c_str());
      pCharacteristic->notify();
      return;
    }

    // FIXME: Remove
    Serial.printf("SSID: %s\n", ssid.c_str());
    Serial.printf("Password: %s\n", password.c_str());

    // Try to connect to the wifi with the given ssid and password
    WiFi.begin(ssid.c_str(), password.c_str());

    // Wait for connection
    unsigned long startTime = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - startTime < 5000) {
      delay(500);
      Serial.println("Connecting to WiFi...");
    }

    if (WiFi.status() == WL_CONNECTED) {
      // Return "ok" as a response if the SSID and password are saved correctly.
      Serial.println("Connected to the WiFi network");
      BLEResponse bleResponse{"ok", "connected", parent.deviceInfo};
      const auto okResponse = serializeBLEResponse(bleResponse);
      pCharacteristic->setValue(okResponse.c_str());
      pCharacteristic->notify();
      Serial.println("notify done");
      parent.requestDisconnect();
    } else {
      Serial.println("Failed to connect to the WiFi network");
      // 接続試行が失敗した原因がパスワードの不一致である可能性が高いため、
      // パスワードエラーのレスポンスを送信する
      BLEResponse bleResponse{"error", "password_incorrect"};
      const auto errorResponse = serializeBLEResponse(bleResponse);
      pCharacteristic->setValue(errorResponse.c_str());
      pCharacteristic->notify();
    }
  }
}

最終的には、パスワードエラーの時には、エラーをSnackBarとして表示し、パスワードが正しかった倍にはWi-Fi接続が完了し、次の画面に進むように実装できた。

しかし、その後、スキャンしたWi-Fiリスト結果が多すぎると、BLE通信のパケットサイズを超えてしまうというエラーに遭遇したため、分割送信に対応。その記事はこちら。

コメント

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