본 글에서는 샤오미 블루투스 온도계 MJWSD05MMC를 커스텀 펌웨어로 플래싱하고, iOS 앱에 연결하는 전체 과정을 상세하게 다룬다. 샤오미 온도계는 원래 Mi Home 앱을 통해서만 데이터를 확인할 수 있지만, 커스텀 펌웨어를 적용하면 표준 BLE(Bluetooth Low Energy) 프로토콜을 통해 어떤 디바이스에서든 온도, 습도, 배터리 데이터를 직접 읽어올 수 있다.
| 준비물 | 설명 |
|---|---|
| 샤오미 MJWSD05MMC | 블루투스 온도습도계 본체 |
| 웹 브라우저 지원 PC/Mac | Chrome이나 Edge 브라우저가 필요하다 |
| iOS 15.0 이상 아이폰 | 앱을 설치할 디바이스 |
샤오미 온도계의 기본 펌웨어는 다음과 같은 제한이 있다:
pvvx/ATC_MiThermometer 커스텀 펌웨어를 적용하면 다음 기능을 얻게 된다:
먼저 Chrome 또는 Edge 브라우저에서 다음 URL에 접속한다:
https://pvvx.github.io/ATC_MiThermometer/TelinkMiFlasher.html
이 웹 페이지는 Web Bluetooth API를 사용하여 브라우저에서 직접 BLE 기기에 연결하고 펌웨어를 플래싱할 수 있게 해준다. 별도의 프로그램 설치가 필요 없어 매우 편리하다.
LYWSD03MMC 또는 비슷한 이름의 기기를 찾아 선택한다. MJWSD05MMC의 경우 MJ_HT_V1이나 LYWSD로 시작하는 이름으로 표시될 수 있다.연결이 완료되면 웹 페이지에 다음 정보가 표시된다:
Device: MJWSD05MMC
MAC: XX:XX:XX:XX:XX:XX
Firmware Version: X.X.X (Original)
Battery: XX%
이 단계에서 현재 설치된 펌웨어가 원본(Original)인지 확인할 수 있다. 이미 커스텀 펌웨어가 설치되어 있다면 버전 정보가 다르게 표시된다.
ATC_v45.bin 또는 최신 버전 파일을 선택한다.⚠️ 주의사항:
- 플래싱 중 브라우저 창을 닫으면 안 된다
- 플래싱 중 배터리가 방전되면 기기가 손상될 수 있다
플래싱이 완료되면 온도계가 자동으로 재부팅된다. 새로운 펌웨어가 적용되면:
ATC_XXXXXX 또는 BTE_XXXXXX 형태로 변경된다. (XXXXXX는 MAC 주소의 마지막 6자리)iOS 앱은 Clean Architecture 패턴을 따르며, 다음과 같은 구조로 구성하였다.
microorganisms/
├── Data/
│ ├── DataSources/
│ │ └── BluetoothDataSource.swift ← BLE 통신 담당
│ └── Models/
├── Domain/
│ ├── Entities/
│ └── UseCases/
└── Presentation/
├── ViewModels/
└── Views/
커스텀 펌웨어가 적용된 샤오미 온도계는 표준 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 | 서비스/특성 | 설명 |
|---|---|---|
| 0x181A | Environmental Sensing Service | 환경 센싱 데이터를 담는 표준 서비스 |
| 0x2A6E | Temperature Characteristic | 온도 데이터 (0.01°C 단위) |
| 0x2A6F | Humidity Characteristic | 습도 데이터 (0.01% 단위) |
| 0x180F | Battery Service | 배터리 정보를 담는 표준 서비스 |
| 0x2A19 | Battery Level Characteristic | 배터리 잔량 (0-100%) |
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 업데이트가 안전하게 이루어진다.
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 기기가 검색된다. 하지만 실제 앱에서는 너무 많은 기기가 표시될 수 있으므로, 콜백에서 기기 이름으로 필터링하는 것이 좋다.
스캔 중 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_ 접두사로 시작하는 이름을 가지므로, 이를 기준으로 필터링하여 원하는 기기만 목록에 추가한다.
사용자가 기기를 선택하면 연결을 시도한다:
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 콜백이 호출된다. 타임아웃을 설정해두면 블루투스 연결이 무한정 대기하는 상황을 방지할 수 있다.
연결이 성공하면 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 콜백이 호출되고, 여기서 각 서비스의 특성을 추가로 검색한다.
특성이 발견되면 현재 값을 읽고 변경 알림을 구독한다:
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 콜백이 호출된다. 이를 통해 실시간으로 온도/습도 변화를 감지할 수 있다.
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
}
}
데이터 파싱에서 주의해야 할 점은 다음과 같다:
Int16(부호 있는 16비트 정수)로 읽어야 영하 온도를 올바르게 표현할 수 있다. 0.01°C 단위이므로 100으로 나눠야 실제 섭씨 온도가 된다.UInt16(부호 없는 16비트 정수)로 읽는다. 습도는 항상 양수이므로 부호 없는 타입을 사용한다.iOS 앱에서 블루투스를 사용하려면 Info.plist에 권한 설명을 추가해야 한다:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>온도계와 연결하여 온도 및 습도 데이터를 읽어옵니다.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>온도계와 연결하여 온도 및 습도 데이터를 읽어옵니다.</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
각 키의 역할은 다음과 같다:
| 키 | 설명 |
|---|---|
| NSBluetoothAlwaysUsageDescription | iOS 13 이상에서 블루투스 접근 권한 요청 시 표시되는 메시지 |
| NSBluetoothPeripheralUsageDescription | iOS 12 이하 호환성을 위한 설정 |
| UIBackgroundModes/bluetooth-central | 앱이 백그라운드에서도 BLE 통신을 유지할 수 있게 해준다 |
원인 및 해결 방법:
원인 및 해결 방법:
UIBackgroundModes를 설정해야 한다.원인 및 해결 방법:
Int16, 습도는 UInt16으로 파싱해야 한다.샤오미 MJWSD05MMC 온도계를 커스텀 펌웨어로 플래싱하고 iOS 앱에서 BLE로 연결하는 전체 과정을 다뤘다. 핵심 내용을 정리하면 다음과 같다.
이러한 방식으로 샤오미의 저렴하고 정확한 온도계를 활용하여 다양한 IoT 프로젝트에 적용할 수 있다. 홈 오토메이션, 식물 관리, 서버실 모니터링 등 다양한 용도로 활용이 가능하다.