Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

🛰️ Android | 워치-폰 동기화 + 위치(x,y,z)+고도 융합(칼만/EMA) 기능 통합 작업 기록

Billcorea 2025. 12. 3. 15:53
반응형

 

🛰️ Android | 워치-폰 동기화 + 위치(x,y,z)+고도 융합(칼만/EMA) 기능 통합 작업 기록

폰앱과 워치앱의 동기 설정

개요 (Intro)

  • 📅 날짜: 2025.12.03
  • 🎯 목표: Wear 앱에서 측정한 위치(x,y,z)와 고도 정보를 Phone 앱에서 실시간으로 표시하고, 폰의 설정 화면에서 모든 워치 설정(센서 프로파일, 융합 비율, 필터 파라미터 등)을 관리하도록 구조 변경.
  • 🧰 기술: Kotlin, Android (Compose), HealthConnect/Google Fit APIs, Wearable Message API, Coroutines, DataStore

문제 정의 (Problem / Motivation)

이번 작업의 주요 문제는 다음과 같습니다:

  • 워치에서 측정된 위치/고도 데이터를 폰에서 실시간으로 표시하지 못하는 문제(동기화 로직/리스너 설정 점검 필요).
  • 설정이 워치 쪽에 분산되어 있어 관리가 불편하고 동기화 ACK가 없어 적용 여부를 알 수 없음.
  • 고도 측정 노이즈 문제로 단순 가중합(fusion ratio) 대신 안정적인 필터(EMA 또는 Kalman)를 적용하고 싶음.
  • 런타임/컴파일 중에 발생한 스크립트/빌드 오류(예: Gradle catalog toml, 코틀린 unresolved reference 등)를 해결하면서 개발 흐름을 막지 않도록 할 필요.
// 예시 에러 스니펫 (빌드 로그 발췌)
// java.lang.IllegalStateException: Must not be called on the main application thread
// at com.google.android.gms.tasks.Tasks.await(....)

해결 과정 (How I Solved It)

작업은 크게 4단계로 진행했습니다.

  1. 데이터 수신/상태 저장: 워치에서 보내는 LocationPayload/Altitude 업데이트를 ViewModel에 연결하여 StateFlow로 보관.
  2. 설정 중앙화: 워치 설정을 Phone의 SettingsScreen으로 이동, 변경 시 SettingsPayload를 워치로 전송하고 ACK 수신시 적용 완료 시각 표시.
  3. 고도 필터링: 간단한 EMA(지수평활)와 1D 칼만 필터(상태벡터: [altitude, velocity]) 두 가지 구현을 추가하여 UI에서 선택 가능하도록 함.
  4. 실시간 스트리밍 전송 제어: requestLocationStreaming 호출을 IO 디스패처로 옮겨 메인 스레드 예외를 방지하고, 노드가 연결되어 있지 않은 경우를 안전하게 처리.

핵심 코드 및 설명

1) IO에서 Wearable 메시지를 보내도록 수정 (메인 스레드에서 Tasks.await 호출 시 예외 발생)

// requestLocationStreaming: 메인 스레드에서 Tasks.await 호출하면 안 됨 -> Dispatchers.IO로 실행
fun requestLocationStreaming(enable: Boolean) {
    viewModelScope.launch(Dispatchers.IO) {
        val ctx = wearDataSyncManager.appContext
        val nodeClient = Wearable.getNodeClient(ctx)
        val messageClient = Wearable.getMessageClient(ctx)
        val nodes = Tasks.await(nodeClient.connectedNodes) // blocking, IO에서 안전
        val cmd = if (enable) "{\"loc_stream\":true}" else "{\"loc_stream\":false}"
        nodes.forEach { node ->
            // sendMessage는 비동기지만 여기서는 await로 보장
            Tasks.await(messageClient.sendMessage(node.id, "/location/stream/cmd", cmd.toByteArray()))
        }
    }
}

설명: 이 변경으로 "Must not be called on the main application thread" 예외를 제거했습니다. UI 클릭은 메인에서 처리되지만 실제 네트워크/노드 호출은 IO로 위임합니다.

2) ViewModel에서 수신된 위치/고도 데이터를 Phone UI로 노출 (StateFlow)

// ViewModel 내부에 StateFlow 변수로 보관
private val _locX = MutableStateFlow<Double?>(null)
private val _locY = MutableStateFlow<Double?>(null)
private val _locZ = MutableStateFlow<Double?>(null)
private val _altitudeMeters = MutableStateFlow<Double?>(null)

val locX: StateFlow<Double?> = _locX
val locY: StateFlow<Double?> = _locY
val locZ: StateFlow<Double?> = _locZ
val altitudeMeters: StateFlow<Double?> = _altitudeMeters

// WearDataSyncManager.Listener 의 onLocationUpdate 에서 값 갱신
override fun onLocationUpdate(loc: LocationPayload) {
    _locX.value = loc.xEast
    _locY.value = loc.yNorth
    _locZ.value = loc.zFused
}

이 값들은 Compose의 SettingsScreen / MainScreen에서 collectAsState로 받아 실시간 표시됩니다.

3) 간단한 EMA(지수 이동 평균) 예시

// EMA(지수 이동 평균)로 고도 노이즈 억제
class EmaFilter(private val alpha: Double) {
    private var state: Double? = null
    fun update(measurement: Double): Double {
        state = if (state == null) measurement else alpha * measurement + (1 - alpha) * state!!
        return state!!
    }
}

// 사용 예
val ema = EmaFilter(alpha = 0.3)
val smoothed = ema.update(rawAltitude)

4) 1D 칼만 필터(상태: [alt, vel]) 기본 구현 (초기값/노이즈 파라미터는 UI에서 튜닝 가능)

// 1D Kalman filter for altitude + velocity
class SimpleKalmanAlt(var qAlt: Double = 0.02, var qVel: Double = 0.05, var rMeas: Double = 0.5) {
    // 상태: [altitude, velocity]
    private var x = doubleArrayOf(0.0, 0.0)
    // 공분산 행렬 P (2x2)
    private var P = arrayOf(doubleArrayOf(1.0, 0.0), doubleArrayOf(0.0, 1.0))

    fun predict(dtSeconds: Double) {
        // 상태 전이: x' = F x, F = [[1, dt],[0,1]]
        val F = arrayOf(doubleArrayOf(1.0, dtSeconds), doubleArrayOf(0.0, 1.0))
        val xNew0 = F[0][0]*x[0] + F[0][1]*x[1]
        val xNew1 = F[1][0]*x[0] + F[1][1]*x[1]
        x[0] = xNew0; x[1] = xNew1
        // P = F P F^T + Q
        val Q = arrayOf(doubleArrayOf(qAlt, 0.0), doubleArrayOf(0.0, qVel))
        // P = simple 2x2 multiply (omitted for brevity - implement proper matrix math)
        // For clarity: in production use a small matrix utility or library.
    }

    fun update(measAlt: Double) {
        // measurement matrix H = [1, 0]
        // Innovation y = z - H x
        val y = measAlt - x[0]
        val S = P[0][0] + rMeas // S = H P H^T + R
        val K0 = P[0][0] / S
        val K1 = P[1][0] / S
        // x = x + K * y
        x[0] += K0 * y
        x[1] += K1 * y
        // P = (I - K H) P
        val P00 = (1 - K0) * P[0][0]
        val P10 = (1 - K0) * P[1][0]
        val P01 = P[0][1] - K0 * P[0][1]
        val P11 = P[1][1] - K1 * P[0][1]
        P[0][0] = P00; P[1][0] = P10; P[0][1] = P01; P[1][1] = P11
    }

    fun getAltitude() = x[0]
}

설명: 위 칼만 예시는 축약형입니다. 실제 행렬 연산(특히 P 갱신)은 완전한 2x2 수식으로 구현해야 합니다. UI에서 Q/R 값을 변경하면 필터 반응성을 실시간으로 튜닝할 수 있도록 했습니다.


결과 (Result)

  • 폰 앱에서 워치로부터 전달된 x,y,z 및 고도 값이 StateFlow로 업데이트되어 Compose 화면에 실시간으로 표시됩니다.
  • 설정은 폰의 SettingsScreen으로 이동했고, 변경 시 SettingsPayload를 워치로 전송하여 워치가 적용하도록 함. 워치에서 ACK(SettingPayload)를 받으면 SettingsScreen 상단에 "적용 완료 시각"을 표시하도록 설계했습니다.
  • requestLocationStreaming 호출을 IO에서 처리하도록 수정하여 메인 스레드 예외를 해결했습니다.
  • 간단한 EMA와 칼만 필터를 추가해 고도 노이즈를 줄일 수 있는 기반을 마련했습니다. DataStore에 설정을 저장하여 재실행 시 복원됩니다.
✅ 동기화 플로우: 워치 → WearDataSyncManager → ViewModel StateFlow → Compose UI (폰) 으로 실시간 노출 확인

느낀 점 / 회고 (Reflection)

  • 메인 스레드에서 블로킹 호출(Tasks.await)을 처리하면 안 되는 건 자주 발생하는 실수였습니다. 네트워크/IO/블로킹은 반드시 IO/Default 디스패처에서 수행해야 합니다.
  • 필터(EMA vs. Kalman)는 복잡도와 튜닝 난이도의 트레이드오프가 있습니다. 우선은 EMA로 빠르게 안정화하고, 필요한 경우 칼만을 계층적으로 적용하는 접근이 현실적입니다.
  • 폰에서 모든 설정을 중앙화하면 UX가 좋아지고 디버깅도 쉬워집니다. ACK 피드백은 사용자의 신뢰를 높입니다.

참고자료 (References)

  • Google Wearable Message API: https://developer.android.com/training/wearables/data-layer/messages
  • Kalman Filter 개요: https://en.wikipedia.org/wiki/Kalman_filter
  • Coroutines + IO dispatcher 패턴: 공식 코루틴 문서

 

반응형