개발 이야기/개발 및 서비스

ESP32와 Flutter간 Bluetooth 통신

가온아 2025. 6. 2. 09:00

2025.05.19 - [개발 이야기/개발 및 서비스] - ESP32 IDF + VSCode 환경 구축

이번 포스팅에서는 Flutter 앱에서 Bluetooth Low Energy (BLE) 장치를 스캔하고 연결하는 방법을 알아보겠습니다. IoT 장치와의 연동이나 웨어러블 기기 제어 등 다양한 분야에서 활용될 수 있는 중요한 기능이니, 차근차근 따라오시면 어렵지 않게 구현하실 수 있을 거예요.


1. flutter_blue 패키지 추가하기

Flutter에서 BLE 기능을 사용하려면 flutter_blue 패키지를 사용하는 것이 일반적입니다. 먼저 pubspec.yaml 파일에 다음과 같이 패키지를 추가해주세요.

dependencies:  
  flutter:  
    sdk: flutter  
  flutter_blue: ^0.8.0 # 최신 버전으로 업데이트될 수 있습니다.  

패키지 버전은 현재 시점을 기준으로 0.8.0을 사용했지만, 이 글을 보시는 시점에는 더 최신 버전이 있을 수 있으니 pub.dev에서](https://pub.dev/packages/flutter_blue)에서) 최신 버전을 확인하시는 것을 권장합니다.

패키지를 추가했다면 터미널에서 다음 명령어를 실행하여 의존성을 설치합니다.

flutter pub get  

2. 플랫폼별 권한 설정

BLE 기능을 사용하기 위해서는 Android와 iOS 각 플랫폼에서 필요한 권한을 명시적으로 요청해야 합니다.

Android 권한 설정

android/app/src/main/AndroidManifest.xml 파일을 열고 <application> 태그 위에 다음 권한들을 추가해주세요.

<uses-permission android:name="android.permission.BLUETOOTH" />  
<uses-permission android name="android.permission.BLUETOOTH_ADMIN" />  
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />  
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />  

<application  
    ...  
</application>  

참고: Android 6.0 (API 23)부터는 BLE 스캔을 위해 위치 권한이 필요합니다. 이는 BLE 스캔을 통해 사용자의 위치를 추정할 수 있기 때문입니다. 따라서 ACCESS_COARSE_LOCATION 또는 ACCESS_FINE_LOCATION 권한이 필수적입니다.

iOS 권한 설정

ios/Runner/Info.plist 파일을 열고 <dict> 태그 안에 다음 키-값 쌍을 추가해주세요.

<dict>  
    <key>NSBluetoothAlwaysUsageDescription</key>  
    <string>Need BLE permission</string>  
    <key>NSBluetoothPeripheralUsageDescription</key>  
    <string>Need BLE permission</string>  
    <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>  
    <string>Need Location permission</string>  
    <key>NSLocationAlwaysUsageDescription</key>  
    <string>Need Location permission</string>  
    <key>NSLocationWhenInUseUsageDescription</key>  
    <string>Need Location permission</string>  
        ...  
</dict>  

iOS에서도 마찬가지로 BLE 사용을 위한 Bluetooth 권한과 위치 기반 서비스 (BLE 스캔)를 위한 위치 권한을 요청해야 합니다. NSBluetoothAlwaysUsageDescription 등 각 키에 대한 string 값은 사용자에게 권한 요청 시 보여지는 문구이므로, 앱의 목적에 맞게 명확하게 작성하는 것이 좋습니다.


3. BLE 스캔 및 연결 샘플 코드

이제 Flutter 앱에서 BLE 장치를 스캔하고 선택하여 연결하는 간단한 샘플 코드를 살펴보겠습니다.

import 'package:flutter/material.dart';  
import 'package:flutter_blue/flutter_blue.dart';  

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

class MyApp extends StatelessWidget {  
  const MyApp({super.key});  

  @override  
  Widget build(BuildContext context) {  
    return MaterialApp(  
      title: 'Flutter BLE Scan Example', // 앱 제목 추가  
      theme: ThemeData(primarySwatch: Colors.blue), // 앱 테마 설정  
      home: BleHomePage(),  
    );  
  }  
}  

class BleHomePage extends StatefulWidget {  
  const BleHomePage({super.key});  

  @override  
  State<BleHomePage> createState() => _BleHomePageState();  
}  

class _BleHomePageState extends State<BleHomePage> {  
  FlutterBlue flutterBlue = FlutterBlue.instance;  
  List<BluetoothDevice> devicesList = []; // 스캔된 장치 목록  

  // 장치 스캔 시작  
  void scanDevices() {  
    devicesList.clear(); // 기존 목록 초기화  
    flutterBlue.startScan(timeout: const Duration(seconds: 4)); // 4초 동안 스캔  
    // 스캔 결과를 리스닝하여 장치 목록 업데이트  
    flutterBlue.scanResults.listen((results) {  
      for (ScanResult r in results) {  
        // 이미 목록에 없는 장치만 추가  
        if (!devicesList.contains(r.device)) {  
          setState(() {  
            devicesList.add(r.device);  
          });  
        }  
      }  
    });  
    // 스캔이 끝나면 stopScan을 호출하여 리소스 낭비 방지  
    flutterBlue.stopScan();  
  }  

  @override  
  void initState() {  
    super.initState();  
    // 앱 시작 시 Bluetooth 상태를 확인하고 사용자에게 활성화 요청  
    flutterBlue.state.listen((state) {  
      if (state == BluetoothState.off) {  
        // Bluetooth가 꺼져 있다면 사용자에게 켜달라고 요청하는 UI를 띄울 수 있습니다.  
        // 예를 들어, AlertDialog를 통해 사용자에게 안내합니다.  
        _showBluetoothOffDialog();  
      }  
    });  
  }  

  // Bluetooth가 꺼져 있을 때 보여줄 다이얼로그  
  void _showBluetoothOffDialog() {  
    showDialog(  
      context: context,  
      builder: (BuildContext context) {  
        return AlertDialog(  
          title: const Text('Bluetooth Off'),  
          content: const Text('Please turn on Bluetooth to use this app.'),  
          actions: <Widget>[  
            TextButton(  
              child: const Text('OK'),  
              onPressed: () {  
                Navigator.of(context).pop();  
              },  
            ),  
          ],  
        );  
      },  
    );  
  }  

  @override  
  Widget build(BuildContext context) {  
    return Scaffold(  
      appBar: AppBar(title: const Text('BLE 장치 스캔')),  
      body: devicesList.isEmpty  
          ? const Center(child: Text('장치를 스캔하려면 돋보기 아이콘을 눌러주세요.')) // 스캔 전 메시지  
          : ListView.builder(  
        itemCount: devicesList.length,  
        itemBuilder: (context, index) {  
          BluetoothDevice d = devicesList[index];  
          return ListTile(  
            title: Text(d.name.isEmpty ? '(이름 없음)' : d.name), // 장치 이름 표시  
            subtitle: Text(d.id.toString()), // 장치 ID (MAC 주소) 표시  
            onTap: () async {  
              try {  
                await d.connect(); // 장치 연결 시도  
                ScaffoldMessenger.of(context).showSnackBar(  
                  SnackBar(content: Text('${d.name.isEmpty ? '이름 없는 장치' : d.name}에 연결되었습니다!')),  
                );  
                // TODO: 연결 후 서비스 탐색 및 데이터 송수신 로직 구현  
                // 예: Navigator.push(context, MaterialPageRoute(builder: (context) => DeviceControlScreen(device: d)));  
              } catch (e) {  
                ScaffoldMessenger.of(context).showSnackBar(  
                  SnackBar(content: Text('연결 실패: $e')),  
                );  
              }  
            },  
          );  
        },  
      ),  
      floatingActionButton: FloatingActionButton(  
        onPressed: scanDevices, // 돋보기 아이콘 클릭 시 스캔 시작  
        child: const Icon(Icons.search),  
      ),  
    );  
  }  
}  

코드 설명

  • MyApp: 앱의 기본 구조를 설정하는 위젯입니다. MaterialApp을 사용하여 앱의 제목과 테마를 설정했습니다.
  • BleHomePage: BLE 스캔 및 장치 목록을 표시하는 메인 페이지입니다.
  • _BleHomePageState:
    • flutterBlue: flutter_blue 패키지의 인스턴스를 가져옵니다. 이 인스턴스를 통해 BLE 기능을 제어합니다.
    • devicesList: 스캔된 BLE 장치들을 저장할 리스트입니다.
    • scanDevices(): 이 함수가 호출되면 BLE 스캔을 시작합니다.
      • flutterBlue.startScan(timeout: const Duration(seconds: 4)): 4초 동안 주변 BLE 장치를 스캔합니다. timeout을 설정하여 무한정 스캔하는 것을 방지할 수 있습니다.
      • flutterBlue.scanResults.listen((results) {...}): 스캔 결과가 나올 때마다 results 리스트를 통해 장치 정보를 받아옵니다. 새로운 장치가 발견되면 devicesList에 추가하고 UI를 업데이트합니다.
      • flutterBlue.stopScan(): 스캔이 끝나면 자동으로 스캔을 중지하도록 설정했습니다. 이는 배터리 소모를 줄이는 데 중요합니다.
    • initState(): 위젯이 처음 생성될 때 호출됩니다. 여기서는 Bluetooth 모듈의 현재 상태를 리스닝하여 Bluetooth가 꺼져 있을 경우 사용자에게 알림을 줍니다.
    • _showBluetoothOffDialog(): Bluetooth가 꺼져 있을 때 사용자에게 Bluetooth를 켜도록 안내하는 다이얼로그를 보여줍니다.
    • build():
      • AppBar: 앱 상단에 제목을 표시합니다.
      • body: devicesList가 비어있으면 스캔 요청 메시지를, 장치가 있다면 ListView.builder를 사용하여 스캔된 장치 목록을 표시합니다.
      • ListTile: 각 장치의 이름과 ID를 보여주고, 장치를 탭하면 d.connect()를 호출하여 해당 장치에 연결을 시도합니다. 연결 성공/실패 여부를 SnackBar로 사용자에게 알립니다.
      • FloatingActionButton: 하단에 돋보기 아이콘 버튼을 추가하여 이 버튼을 누르면 scanDevices() 함수가 호출되어 BLE 스캔을 시작합니다.

4. 실행 및 테스트

이제 앱을 실행하고 하단의 돋보기 아이콘을 눌러보세요. 주변의 BLE 장치들이 리스트에 나타나는 것을 확인할 수 있습니다. 리스트에 표시된 장치 중 연결을 원하는 디바이스를 선택하면 해당 장치와 연결을 시도합니다.

연결에 성공했다면, 이제 해당 장치의 서비스(Service)와 특성(Characteristic)을 탐색하여 데이터를 읽거나 쓰는 등 본격적인 BLE 통신을 구현할 수 있습니다. 이는 다음 단계에서 더 자세히 다루겠지만, 기본적인 스캔 및 연결 과정은 위 코드로 충분합니다.


참고:

  • 실제 BLE 통신: 이 샘플 코드는 BLE 장치 스캔 및 연결까지만 다룹니다. 연결된 장치와 데이터를 주고받기 위해서는 장치의 서비스 UUID, 특성 UUID 등을 알아내고 flutter_blue 패키지의 discoverServices(), readCharacteristic(), writeCharacteristic() 등의 메서드를 활용해야 합니다.
  • 에러 처리: 실제 앱 개발 시에는 Bluetooth가 꺼져 있거나, 권한이 부여되지 않았거나, 장치 연결에 실패하는 등 다양한 예외 상황에 대한 견고한 에러 처리가 필수적입니다.
  • 백그라운드 스캔: 앱이 백그라운드에 있을 때도 스캔을 계속하려면 추가적인 설정과 권한이 필요하며, 플랫폼별 제약 사항을 고려해야 합니다.

이 포스팅이 Flutter 앱에서 BLE 기능을 시작하는 데 도움이 되었기를 바랍니다. 질문이 있으시면 언제든지 댓글로 남겨주세요!

반응형