반응형
🛰️ 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단계로 진행했습니다.
- 데이터 수신/상태 저장: 워치에서 보내는 LocationPayload/Altitude 업데이트를 ViewModel에 연결하여 StateFlow로 보관.
- 설정 중앙화: 워치 설정을 Phone의 SettingsScreen으로 이동, 변경 시 SettingsPayload를 워치로 전송하고 ACK 수신시 적용 완료 시각 표시.
- 고도 필터링: 간단한 EMA(지수평활)와 1D 칼만 필터(상태벡터: [altitude, velocity]) 두 가지 구현을 추가하여 UI에서 선택 가능하도록 함.
- 실시간 스트리밍 전송 제어: 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 패턴: 공식 코루틴 문서
반응형
'모바일 앱(안드로이드)' 카테고리의 다른 글
| 🧭 Android | 위치 수신·히스토리 저장 및 Vico 그래프 NaN 크래시 대응 (0) | 2025.12.07 |
|---|---|
| ⌚ Wear OS | 센서 수명주기·권한·동기화 연결로 기본 데이터 파이프 완성 (2) | 2025.12.05 |
| 🛠 Wear + Phone Altitude 동기화 & 설정화면 컴파일 오류 해결 요약 (1) | 2025.12.01 |
| 🖐 Wear OS | 폰-워치 동기화와 큰절 제스처 시작 표식, 설정 화면 표시까지 (0) | 2025.11.29 |
| 🛠 Android | Coupang API + Hilt DI + AdsScreen UX/포맷 개선 작업 기록 (1) | 2025.11.25 |