샤오미 온도계 데이터를 아이폰에서 불러오기

cheshire0105·2026년 1월 11일

iOS

목록 보기
46/46

들어가며

본 글에서는 샤오미 블루투스 온도계 MJWSD05MMC를 커스텀 펌웨어로 플래싱하고, iOS 앱에 연결하는 전체 과정을 상세하게 다룬다. 샤오미 온도계는 원래 Mi Home 앱을 통해서만 데이터를 확인할 수 있지만, 커스텀 펌웨어를 적용하면 표준 BLE(Bluetooth Low Energy) 프로토콜을 통해 어떤 디바이스에서든 온도, 습도, 배터리 데이터를 직접 읽어올 수 있다.

1. 준비물 확인

필수 하드웨어

준비물설명
샤오미 MJWSD05MMC블루투스 온도습도계 본체
웹 브라우저 지원 PC/MacChrome이나 Edge 브라우저가 필요하다
iOS 15.0 이상 아이폰앱을 설치할 디바이스

중요 사항

  • 플래싱 작업은 Chrome 또는 Edge 브라우저에서만 가능하다. Safari는 Web Bluetooth API를 지원하지 않기 때문에 사용할 수 없다.
  • 온도계의 배터리가 충분히 충전되어 있는지 확인해야 한다. 플래싱 중 전원이 꺼지면 기기가 벽돌(brick) 상태가 될 수 있다.
  • 내가 가지고 있는 온도계 기기인 MJWSD05MMC 이 기기는 조금 페어링이 특이한데, 버튼 두개를 길게 누른다. 그리고 화면이 깜빡 거리면 위에 버튼 한번 아래 버튼 한번을 해야 페어링이 된다.

2. 커스텀 펌웨어의 필요성

왜 커스텀 펌웨어가 필요한가?

샤오미 온도계의 기본 펌웨어는 다음과 같은 제한이 있다:

  1. 암호화된 BLE 통신: 샤오미는 자체 암호화 프로토콜을 사용하여 Mi Home 앱 외에는 데이터를 읽을 수 없다.
  2. 제한된 광고(Advertisement) 데이터: 표준 BLE 스캔으로는 온도/습도 정보를 직접 읽을 수 없다.

ATC 커스텀 펌웨어의 장점

pvvx/ATC_MiThermometer 커스텀 펌웨어를 적용하면 다음 기능을 얻게 된다:

  • 표준 BLE GATT 서비스 지원
  • 암호화 없는 오픈 통신으로 어떤 BLE 클라이언트에서든 접근 가능
  • 배터리 서비스 (0x180F) 표준 지원
  • 광고 패킷에 온도/습도 정보 포함

3. 커스텀 펌웨어 플래싱 과정

3.1 TelinkMiFlasher 웹 도구 접속

먼저 Chrome 또는 Edge 브라우저에서 다음 URL에 접속한다:

https://pvvx.github.io/ATC_MiThermometer/TelinkMiFlasher.html

이 웹 페이지는 Web Bluetooth API를 사용하여 브라우저에서 직접 BLE 기기에 연결하고 펌웨어를 플래싱할 수 있게 해준다. 별도의 프로그램 설치가 필요 없어 매우 편리하다.

3.2 블루투스 연결

  1. "Connect" 버튼 클릭: 페이지 상단의 파란색 Connect 버튼을 클릭한다.
  2. 기기 선택 팝업: 브라우저에서 블루투스 기기 목록이 표시된다.
  3. 온도계 선택: LYWSD03MMC 또는 비슷한 이름의 기기를 찾아 선택한다. MJWSD05MMC의 경우 MJ_HT_V1이나 LYWSD로 시작하는 이름으로 표시될 수 있다.
  4. 페어링 완료: 연결이 성공하면 기기 정보(MAC 주소, 펌웨어 버전 등)가 화면에 표시된다.

3.3 기기 정보 확인

연결이 완료되면 웹 페이지에 다음 정보가 표시된다:

Device: MJWSD05MMC
MAC: XX:XX:XX:XX:XX:XX
Firmware Version: X.X.X (Original)
Battery: XX%

이 단계에서 현재 설치된 펌웨어가 원본(Original)인지 확인할 수 있다. 이미 커스텀 펌웨어가 설치되어 있다면 버전 정보가 다르게 표시된다.

3.4 커스텀 펌웨어 선택

  1. "Custom Firmware" 섹션으로 이동: 페이지를 스크롤하여 펌웨어 선택 영역을 찾는다.
  2. 펌웨어 파일 선택: "Select Firmware" 드롭다운에서 기기 모델에 맞는 펌웨어를 선택한다.
    • MJWSD05MMC의 경우 ATC_v45.bin 또는 최신 버전 파일을 선택한다.
  3. 설정 옵션 확인: 기본 설정을 그대로 사용해도 되지만, "Advertising type"을 "PVVX"로 설정하면 더 많은 데이터를 광고 패킷에 포함시킬 수 있다.

3.5 플래싱 실행

  1. "Start Flashing" 버튼 클릭: 버튼을 클릭하여 플래싱을 시작한다.
  2. 진행률 확인: 화면에 플래싱 진행률이 표시된다. 일반적으로 30초~1분 정도 소요된다.
  3. 완료 대기: "Flashing complete!" 메시지가 표시될 때까지 절대로 브라우저를 닫거나 기기를 이동시키지 않는다.
⚠️ 주의사항:
- 플래싱 중 브라우저 창을 닫으면 안 된다
- 플래싱 중 배터리가 방전되면 기기가 손상될 수 있다

3.6 플래싱 완료 후 확인

플래싱이 완료되면 온도계가 자동으로 재부팅된다. 새로운 펌웨어가 적용되면:

  1. 디바이스 이름 변경: 기기 이름이 ATC_XXXXXX 또는 BTE_XXXXXX 형태로 변경된다. (XXXXXX는 MAC 주소의 마지막 6자리)
  2. LCD 표시 유지: 화면에 여전히 온도와 습도가 정상적으로 표시된다.
  3. 스마일 아이콘: 일부 버전에서는 BLE 연결 상태를 나타내는 아이콘이 추가된다.

4. iOS 앱에서 블루투스 연결 구현

4.1 프로젝트 구조

iOS 앱은 Clean Architecture 패턴을 따르며, 다음과 같은 구조로 구성하였다.

microorganisms/
├── Data/
│   ├── DataSources/
│   │   └── BluetoothDataSource.swift  ← BLE 통신 담당
│   └── Models/
├── Domain/
│   ├── Entities/
│   └── UseCases/
└── Presentation/
    ├── ViewModels/
    └── Views/

4.2 BLE UUID 상수 정의

커스텀 펌웨어가 적용된 샤오미 온도계는 표준 BLE GATT 서비스를 사용한다. 다음 UUID들을 사용하여 데이터에 접근할 수 있다:

// Environmental Sensing Service (온도 및 습도)
private let environmentalSensingServiceUUID = CBUUID(string: "181A")
private let temperatureCharacteristicUUID = CBUUID(string: "2A6E")
private let humidityCharacteristicUUID = CBUUID(string: "2A6F")

// Battery Service (배터리)
private let batteryServiceUUID = CBUUID(string: "180F")
private let batteryLevelCharacteristicUUID = CBUUID(string: "2A19")

각 UUID의 의미는 다음과 같다:

UUID서비스/특성설명
0x181AEnvironmental Sensing Service환경 센싱 데이터를 담는 표준 서비스
0x2A6ETemperature Characteristic온도 데이터 (0.01°C 단위)
0x2A6FHumidity Characteristic습도 데이터 (0.01% 단위)
0x180FBattery Service배터리 정보를 담는 표준 서비스
0x2A19Battery Level Characteristic배터리 잔량 (0-100%)

4.3 CBCentralManager 초기화

BLE 통신을 시작하려면 CBCentralManager를 초기화해야 한다. 이 객체가 iOS 기기의 블루투스 모듈을 제어하는 역할을 담당한다:

class BluetoothDataSource: NSObject, ObservableObject {
    private var centralManager: CBCentralManager!
    private var connectedPeripheral: CBPeripheral?
    
    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: .main)
    }
}

CBCentralManager를 생성할 때 delegate를 self로 설정하면, 블루투스 상태 변화나 기기 발견 이벤트를 콜백으로 받을 수 있다. queue 매개변수를 .main으로 설정하면 모든 콜백이 메인 스레드에서 실행되어 UI 업데이트가 안전하게 이루어진다.

4.4 기기 스캔 구현

async/await 패턴과 CheckedContinuation을 사용하여 비동기 스캔 메서드를 구현한다:

func scanDevices() async -> [String] {
    return await withCheckedContinuation { continuation in
        scanContinuation = continuation
        discoveredPeripherals.removeAll()
        
        // Bluetooth 상태 확인
        guard centralManager.state == .poweredOn else {
            continuation.resume(returning: [])
            return
        }
        
        // 모든 BLE 기기 스캔 시작
        centralManager.scanForPeripherals(
            withServices: nil,
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
        )
        
        // 5초 후 스캔 중지
        DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
            self?.centralManager.stopScan()
            let deviceNames = Array(self?.discoveredPeripherals.keys ?? [])
            continuation.resume(returning: deviceNames)
        }
    }
}

withServices: nil로 설정하면 모든 BLE 기기가 검색된다. 하지만 실제 앱에서는 너무 많은 기기가 표시될 수 있으므로, 콜백에서 기기 이름으로 필터링하는 것이 좋다.

4.5 기기 발견 콜백 처리

스캔 중 BLE 기기가 발견되면 didDiscover 델리게이트 메서드가 호출된다:

func centralManager(_ central: CBCentralManager, 
                    didDiscover peripheral: CBPeripheral, 
                    advertisementData: [String: Any], 
                    rssi RSSI: NSNumber) {
    let deviceName = peripheral.name ?? peripheral.identifier.uuidString
    
    // 커스텀 펌웨어 온도계만 필터링 (BTE_XXXXXX 형식)
    guard deviceName.contains("BTE_") else {
        return
    }
    
    discoveredPeripherals[deviceName] = peripheral
}

커스텀 펌웨어가 적용된 온도계는 BTE_ 또는 ATC_ 접두사로 시작하는 이름을 가지므로, 이를 기준으로 필터링하여 원하는 기기만 목록에 추가한다.

4.6 기기 연결

사용자가 기기를 선택하면 연결을 시도한다:

func connect(to deviceId: String) async -> Bool {
    guard let peripheral = discoveredPeripherals[deviceId] else {
        return false
    }
    
    return await withCheckedContinuation { continuation in
        connectContinuation = continuation
        connectedPeripheral = peripheral
        peripheral.delegate = self
        centralManager.connect(peripheral, options: nil)
        
        // 10초 타임아웃
        DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
            if self?.connectContinuation != nil {
                self?.connectContinuation?.resume(returning: false)
                self?.connectContinuation = nil
            }
        }
    }
}

연결 요청이 성공하면 didConnect 콜백이 호출되고, 실패하면 didFailToConnect 콜백이 호출된다. 타임아웃을 설정해두면 블루투스 연결이 무한정 대기하는 상황을 방지할 수 있다.

4.7 서비스 및 특성 검색

연결이 성공하면 GATT 서비스와 특성(Characteristic)을 검색한다:

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    // 서비스 검색
    peripheral.discoverServices([
        environmentalSensingServiceUUID,  // 0x181A
        batteryServiceUUID                 // 0x180F
    ])
}

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    peripheral.services?.forEach { service in
        if service.uuid == environmentalSensingServiceUUID {
            peripheral.discoverCharacteristics([
                temperatureCharacteristicUUID,  // 0x2A6E
                humidityCharacteristicUUID      // 0x2A6F
            ], for: service)
        } else if service.uuid == batteryServiceUUID {
            peripheral.discoverCharacteristics([
                batteryLevelCharacteristicUUID  // 0x2A19
            ], for: service)
        }
    }
}

discoverServices 메서드는 원하는 서비스 UUID 배열을 전달받아 해당 서비스만 검색한다. 검색이 완료되면 didDiscoverServices 콜백이 호출되고, 여기서 각 서비스의 특성을 추가로 검색한다.

4.8 데이터 읽기 및 알림 구독

특성이 발견되면 현재 값을 읽고 변경 알림을 구독한다:

func peripheral(_ peripheral: CBPeripheral, 
                didDiscoverCharacteristicsFor service: CBService, 
                error: Error?) {
    service.characteristics?.forEach { characteristic in
        switch characteristic.uuid {
        case temperatureCharacteristicUUID:
            temperatureCharacteristic = characteristic
            peripheral.readValue(for: characteristic)      // 현재 값 읽기
            peripheral.setNotifyValue(true, for: characteristic)  // 변경 알림 구독
            
        case humidityCharacteristicUUID:
            humidityCharacteristic = characteristic
            peripheral.readValue(for: characteristic)
            peripheral.setNotifyValue(true, for: characteristic)
            
        case batteryLevelCharacteristicUUID:
            batteryCharacteristic = characteristic
            peripheral.readValue(for: characteristic)
            peripheral.setNotifyValue(true, for: characteristic)
            
        default:
            break
        }
    }
}

setNotifyValue(true, for:)를 호출하면 해당 특성의 값이 변경될 때마다 자동으로 didUpdateValueFor 콜백이 호출된다. 이를 통해 실시간으로 온도/습도 변화를 감지할 수 있다.

4.9 데이터 파싱

BLE를 통해 수신된 원시 바이트 데이터를 실제 온도/습도/배터리 값으로 변환한다:

func peripheral(_ peripheral: CBPeripheral, 
                didUpdateValueFor characteristic: CBCharacteristic, 
                error: Error?) {
    guard error == nil, let data = characteristic.value else { return }
    
    switch characteristic.uuid {
    case temperatureCharacteristicUUID:
        // 온도: 0.01°C 단위, Little Endian Int16
        if data.count >= 2 {
            let rawValue = data.withUnsafeBytes { $0.load(as: Int16.self) }
            currentTemperature = Double(rawValue) / 100.0
        }
        
    case humidityCharacteristicUUID:
        // 습도: 0.01% 단위, Little Endian UInt16
        if data.count >= 2 {
            let rawValue = data.withUnsafeBytes { $0.load(as: UInt16.self) }
            currentHumidity = Double(rawValue) / 100.0
        }
        
    case batteryLevelCharacteristicUUID:
        // 배터리: 0-100%
        if data.count >= 1 {
            currentBatteryLevel = Int(data[0])
        }
        
    default:
        break
    }
}

데이터 파싱에서 주의해야 할 점은 다음과 같다:

  1. 온도 데이터: Int16(부호 있는 16비트 정수)로 읽어야 영하 온도를 올바르게 표현할 수 있다. 0.01°C 단위이므로 100으로 나눠야 실제 섭씨 온도가 된다.
  2. 습도 데이터: UInt16(부호 없는 16비트 정수)로 읽는다. 습도는 항상 양수이므로 부호 없는 타입을 사용한다.
  3. 배터리 레벨: 단일 바이트로 0~100 사이의 정수 값을 나타낸다.
  4. 바이트 순서: 모든 데이터는 Little Endian(하위 바이트가 먼저)으로 전송된다.

5. Info.plist 설정

iOS 앱에서 블루투스를 사용하려면 Info.plist에 권한 설명을 추가해야 한다:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>온도계와 연결하여 온도 및 습도 데이터를 읽어옵니다.</string>

<key>NSBluetoothPeripheralUsageDescription</key>
<string>온도계와 연결하여 온도 및 습도 데이터를 읽어옵니다.</string>

<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
</array>

각 키의 역할은 다음과 같다:

설명
NSBluetoothAlwaysUsageDescriptioniOS 13 이상에서 블루투스 접근 권한 요청 시 표시되는 메시지
NSBluetoothPeripheralUsageDescriptioniOS 12 이하 호환성을 위한 설정
UIBackgroundModes/bluetooth-central앱이 백그라운드에서도 BLE 통신을 유지할 수 있게 해준다

6. 트러블슈팅

6.1 기기가 검색되지 않는 경우

원인 및 해결 방법:

  1. 블루투스가 꺼져 있다: 설정 > 블루투스에서 블루투스가 켜져 있는지 확인한다.
  2. 권한이 거부되었다: 설정 > 앱 > [앱 이름] > 블루투스에서 권한이 허용되어 있는지 확인한다.
  3. 온도계가 다른 기기에 연결되어 있다: 커스텀 펌웨어 온도계는 동시에 하나의 기기에만 연결할 수 있다. 다른 기기의 연결을 끊어야 한다.
  4. 온도계 배터리가 방전되었다: 배터리를 교체하거나 충전한다.

6.2 연결이 자주 끊어지는 경우

원인 및 해결 방법:

  1. 거리가 너무 멀다: BLE의 유효 범위는 일반적으로 10m 이내이다. 장애물이 있으면 더 짧아진다.
  2. 다른 BLE 기기의 간섭: 주변에 많은 BLE 기기가 있으면 간섭이 발생할 수 있다.
  3. iOS 메모리 정리: 백그라운드에서 앱이 종료되지 않도록 UIBackgroundModes를 설정해야 한다.

6.3 온도/습도 값이 비정상적인 경우

원인 및 해결 방법:

  1. 바이트 순서 오류: Little Endian으로 파싱하고 있는지 확인한다.,
  2. 데이터 타입 오류: 온도는 Int16, 습도는 UInt16으로 파싱해야 한다.
  3. 단위 변환 누락: 100으로 나눠서 0.01 단위를 실제 값으로 변환해야 한다.

7. 마무리

샤오미 MJWSD05MMC 온도계를 커스텀 펌웨어로 플래싱하고 iOS 앱에서 BLE로 연결하는 전체 과정을 다뤘다. 핵심 내용을 정리하면 다음과 같다.

  1. 커스텀 펌웨어: pvvx/ATC_MiThermometer 펌웨어를 사용하면 표준 BLE GATT 서비스를 통해 데이터에 접근할 수 있다.
  2. 웹 플래싱: TelinkMiFlasher 웹 도구를 사용하면 별도의 프로그램 설치 없이 브라우저에서 바로 플래싱할 수 있다.
  3. 표준 UUID
  4. 데이터 파싱

이러한 방식으로 샤오미의 저렴하고 정확한 온도계를 활용하여 다양한 IoT 프로젝트에 적용할 수 있다. 홈 오토메이션, 식물 관리, 서버실 모니터링 등 다양한 용도로 활용이 가능하다.

참고 자료

0개의 댓글