[BLE] ⚡️ BLE Write와 State Restore (3)

2025. 10. 12. 22:53도메인/Bluetooth

Overview

  • BLE Write: Central이 심박수 모니터링 중 심박수가 140을 넘어가면, Peripheral에 경고등을 켜는 기능 구현
  • BLE State Save / Restore - BLE Life cycle Background 진입 또는 종료 후 상태 복구

BLE Write - Central

심박수가 140 이상이면, 경고등을 켜고 다시 정상 수치까지 내려가면 경고등을 끕니다.

custom characteristic을 분기해서 프로퍼티에 저장해놨다가 쓰기 작업이 필요할 때 저장해뒀던 charateristic과 data 그리고 응답 여부를 함께 전송합니다.

// 구독한 값을 수신하면, 디코딩하여 publish
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: (any Error)?) {
    ...

    Task { @MainActor in
        if heartRateValue > 140 {
            self.send(to: peripheral, .redAlert)
        } else if heartRateValue < 125 {
            self.send(to: peripheral, .turnOff)
        }
    }
}

// BLE Write
private func send(to peripheral: CBPeripheral, _ command: Command) {
    guard let alertCharacteristic else { return }
    let commandData = Data(command.rawValue.utf8)

    peripheral.writeValue(commandData, for: alertCharacteristic, type: .withResponse)
    Self.log.info("🚨 Heart Rate command fired: \(command.rawValue)")
}

BLE Write - Peripheral

Peripheral에도 custom chracteristic을 추가해주고, service를 정의할 때 같이 넣어줍니다.

// PeripheralManager.swift

let heartAlertLevelCharateristic = CBMutableCharacteristic(
    type: GATT.Characteristic.heartAlertLevel,
    properties: [.write],
    value: nil,
    permissions: [.writeable]
)

// custom characteristic 추가
private func setupHeartRateService() {
    let heartRateService = CBMutableService(type: GATT.Service.heartRate, primary: true)

    heartRateService.characteristics = [heartRateCharacteristic, heartAlertLevelCharateristic]
    cbPeripheralManager.add(heartRateService)
}

 

Central에서 요청을 보내면 didReceiveWrite delegate 메서드를 통해 받을 수 있습니다. 해당 요청에서 characteristic과 command 값을 추출하고, 해당 값을 적용합니다.

🚨 작업이 완료된 후, peripheral.respond를 해줍니다.

func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
    for request in requests {
        if request.characteristic.uuid == GATT.Characteristic.heartAlertLevel,
           let value = request.value {
            let command = String(data: value, encoding: .utf8)
            Self.log.info("Received command: \(command ?? "unknown")")

            // 메인 스레드에서 UI 업데이트 처리
			...

            // 요청에 응답
            peripheral.respond(to: request, withResult: .success)
        }
    }
}

State 저장

WebSocket 구독 상태와 비슷하게 BLE도 상태를 저장하고, 재진입했을 시 저장한 상태를 불러와 상태를 복원할 수 있습니다.

평소에 프로퍼티가 변경될 때 마다 저장해줍니다. 이후 재진입할 때, 매니저 생성자에서 인스턴스 생성 후, 복구를 시작합니다. 중요한 것은 powerOn 일 때 다시 광고를 시작한다는 점입니다.

 private(set) var status: Status = .idle {
    didSet {
        saveState()
    }
}
...

private(set) var isAdvertising = false {
    didSet {
        saveState()
    }
}

init() {
	...
    await restoreState()
}

private func restoreState() async {
    guard let savedState = await stateManager.loadPeripheralState() else { return }

    Self.log.info("Restoring peripheral state: advertising=\(savedState.isAdvertising), heartRate=\(savedState.heartRateValue), alert=\(savedState.redAlertFlag)")

    // 상태 복원
    heartRateValue = savedState.heartRateValue
    redAlertFlag = savedState.redAlertFlag

    // 상태에 따른 복원
    if let status = Status(rawValue: savedState.status) {
        self.status = status
    }

    // ⚡️ 광고 상태 복원 (전원이 켜진 후에만)
    if savedState.isAdvertising && cbPeripheralManager.state == .poweredOn {
        startAdvertising()
    }
}

 

저장은 UserDefault를 이용했고, validate 과정을 거쳐 1시간 이내에 저장된 것은 복구하고 이후는 저장된 값이 있더라도 무시하도록 하였습니다.

extension BLEStateManager: BLEStateManaging {

// MARK: - Central State Management

func saveCentralState(_ state: CentralState) async {
    savedCentralState = state
    if let encoded = try? JSONEncoder().encode(state) {
        userDefaults.set(encoded, forKey: centralStateKey)
        Self.log.info("Central state saved: scanning=\(state.isScanning), connected=\(state.connectedPeripheralIdentifier ?? "none")")
    }
}

func loadCentralState() async -> CentralState? {
    guard let data = userDefaults.data(forKey: centralStateKey),
          let state = try? JSONDecoder().decode(CentralState.self, from: data) else {
        return nil
    }

    if validate(for: state.lastUpdateTime) {
        Self.log.info("Central state loaded: scanning=\(state.isScanning), connected=\(state.connectedPeripheralIdentifier ?? "none")")
        savedCentralState = state
        return state
    } else {
        savedCentralState = nil
    }
    return nil
}

Next Step

Terminated와 Background mode에서 App을 깨워서 상태 복구하기

 

   전에 전자 도어락 회사를 잠깐 다녔을 때 BLE Life cycle 관련 이슈가 있었는데, 안드로이드는 문제가 없는데, Not Running 상태에서 아파트 동 입구의 도어락이 열리지 않았다.

   한 번 키고 나면 잘 살아있지만, Terminated 되고 난 뒤 자동 복구가 되지 않았다. 그 때 당시에는 50m 넘는 거리 문제와 iOS 13 이후, 최적화 관련 업데이트로인하여 이슈가 생긴것으로 파악했었다.

 

'도메인 > Bluetooth' 카테고리의 다른 글

[BLE] ⚡️ Peripheral 시뮬레이션 (2)  (0) 2025.10.12
[BLE] ⚡️ BLE란? Core Bluetooth (1)  (0) 2025.10.12