갤럭시 워치 울트라2에 하루 건강 그래프 워치 페이스 Infoface 만들기

2026년 10월 21일날씨: 맑음

갤럭시 워치 울트라2(SM-L715N, Wear OS 6)에 쓸 워치 페이스를 직접 만들었다. 울트라2의 기본 페이스 “울트라 인포보드”의 밝은 느낌은 그대로 두고, 가운데에 오늘 0시부터 지금까지의 걸음·칼로리·고도·땀·심박·스트레스 추정을 한 장의 그래프로 겹쳐 그린다. 이름은 Infoface다. 10월 5일에 시작해 보름 넘게 날마다 고쳐 왔는데, 그 과정에서 알게 된 것들을 잊기 전에 정리해 둔다.

갤럭시 워치 울트라2에 설치한 Infoface (2026년 10월 21일 저녁)
10월 21일 저녁, 실제 갤럭시 워치 울트라2에 올린 모습. 스토어가 아니라 개발자 옵션과 adb로 직접 설치해 날마다 쓰고 있다.
Infoface 지금 모습 (2026년 10월 21일)
지금 모습(10월 21일 저녁). 바깥 띠는 나침반 링, 왼쪽은 배터리, 가운데는 0시부터 지금까지의 그래프, 그래프 아래 황토색 칸은 스트레스 추정, 맨 아래는 날씨.

무엇을 만들었나

세 모듈로 되어 있다. 워치 페이스는 코드 없이 Watch Face Format(WFF) XML 한 장으로 그린다. 그래프는 워치 앱이 PNG 그림으로 그려서 컴플리케이션(페이스에 값을 넣어 주는 칸)으로 넘겨준다. 폰 앱은 워치가 매일 밤 보내는 하루치 기록을 받아 두고 일·월·년 단위로 보여 주는 뷰어다.

모듈역할만드는 방법
watchface워치 페이스 “Infoface”. 배치, 모드별 색, 나침반 링, 배터리 게이지, 날씨 아이콘WFF v2 XML (res/raw/watchface.xml) 한 파일
wear워치 앱. 심박·땀을 재고, 걸음·기압을 읽고, 그래프를 그려 컴플리케이션으로 공급. 매일 밤 하루 스냅샷을 폰으로 전송Kotlin, Samsung Health Sensor SDK 1.4.1
mobile폰 앱. 스냅샷을 받아 일·월·년 그래프와 최고·최저를 보여 주는 뷰어Kotlin, Wear OS Data Layer
Infoface 첫 모습 (2026년 10월 5일)
10월 5일 첫 모습. 원 3개가 그래프 아래를 차지했고, 배터리는 무지개 그라데이션, 오른쪽에는 일일 활동 숫자가 있었다. 지금과 비교하면 거의 다른 페이스다.

화면 구성

좌표는 기기 실제 픽셀(498×498, 중심 249,249) 기준이다. 다른 해상도의 워치에서는 WFF가 비율대로 늘리거나 줄인다.

위치내용데이터 출처
맨 바깥 띠나침반 링. 5°·10° 눈금, 30°마다 숫자, N(빨강)·E·S(파랑)·W. 링이 돌아 N이 실제 북쪽을 가리키고, 12시의 고정 주황 ▲가 지금 향한 방위다. 누르면 삼성 나침반 앱워치 앱이 회전 벡터 센서로 방위를 계산해 1초마다 보냄
맨 위날짜 “10월 21일 수”와 시계 hh:mmWFF 기본값
가운데 카드 400×294오늘 0시~지금 그래프. 아래 두 줄에 걸음 수·칼로리·고도 / 스트레스·땀·심박 숫자. 누르면 Infoface 앱 화면워치 앱이 그린 PNG
왼쪽 가장자리배터리 게이지. 1% 단위로 채우고, 막대와 아이콘 색이 남은 양에 따라 서서히 바뀐다. “97%”는 테두리를 따라 휘어진 글자WFF [BATTERY_PERCENT]
아래 가장자리날씨 “🌙 맑음 15° (21°/10°)”. 아이콘, 날씨 이름(삼성 날씨를 고르면 지역 이름), 현재 기온, (오늘 최고/최저). 테두리를 따라 휘어진 글자삼성 날씨 컴플리케이션 + WFF [WEATHER.*]

날씨 아이콘은 직접 그렸다. 삼성 날씨 컴플리케이션은 지역 이름과 기온만 주고 그림은 주지 않기 때문이다. WFF의 날씨 상태 값([WEATHER.CONDITION])을 보고 맑음이면 해(밤이면 초승달), 구름 조금이면 해와 구름, 비면 구름과 빗줄기 같은 식으로 도형을 조합해 그린다.

손목을 내리면(AOD) 검정 바탕에 흰 글자로 바뀐다. 날짜·시계·걸음 수 같은 글자는 페이스가 흰색으로 따로 그리면 되지만, 그래프 카드는 앱이 보내는 그림이라 문제였다. 삼성 런타임은 사진 이미지에 tintColor를 곱하기처럼 적용해서 흰색으로 바꿔 주지 않는다. 그래서 워치 앱이 밝은 화면용과 AOD용 카드 두 장을 위아래로 붙인 400×588 그림 한 장을 보내고, 페이스가 모드에 따라 그림을 위아래로 옮겨 절반만 보이게 한다. 슬롯은 자기 영역 안쪽만 그린다는 특성을 이용한 것이다.

배터리 게이지 색 변화 (100%에서 25%까지)
배터리 90% 이상 파랑, 75% 초록, 50% 노랑, 26% 주황, 25% 이하 빨강. 사이 값은 이웃한 두 색을 섞는다. WFF v2에는 색을 섞는 함수가 없어서 색마다 막대를 겹쳐 놓고 각 막대의 alpha를 값에 따라 바꿔 섞었다. 시험은 adb shell dumpsys battery set level N 으로 했다.

WFF XML에서 색을 섞는 부분이다(watchface.xml). 초록 막대 한 겹의 예인데, 파랑·노랑·주황·빨강도 같은 꼴이고 alpha 식만 다르다. 90%에서 75%로 내려오는 동안 초록의 alpha가 0에서 255로 올라가 아래에 깔린 파랑을 덮고, 50% 아래에서는 사라져 노랑이 드러난다.

<!-- 초록: 90→75% 에서 파랑 위로 0→255, 50~74% 에서는 아래에 깔린다 -->
<Group name="batteryGreen" x="0" y="0" width="122" height="343" alpha="0">
    <Transform target="alpha"
        value="255 * clamp((90 - [BATTERY_PERCENT]) / 15, 0, 1) * clamp([BATTERY_PERCENT] - 49, 0, 1)" />
    <PartDraw x="0" y="0" width="122" height="343">
        <Arc centerX="249" centerY="183" width="422" height="422" startAngle="258" endAngle="258">
            <Transform target="endAngle" value="258 + 52 * clamp([BATTERY_PERCENT] / 100, 0, 1)" />
            <Stroke color="#FF34C759" thickness="12" cap="ROUND" />
        </Arc>
    </PartDraw>
</Group>

그래프 카드: 하루가 흘러가는 모양

처음에는 측정값을 그대로 꺾은선으로 그렸다. 들쭉날쭉해서 무엇을 봐야 할지 알 수 없었고, 항목마다 줄을 나눠도 봤지만 답답했다. 지금은 “실제 값을 뿌린다기보다 시간에 따라 이렇게 흘러간다”는 느낌으로, 모든 항목을 가우시안으로 넓게 평균하고 Catmull-Rom 곡선으로 잇는다. 평균 폭은 0시부터 지난 시간의 1/20(19시면 약 57분)이라 하루가 길어질수록 더 부드러워지고, 1분마다 다시 그릴 때 모양이 스르르 바뀐다.

평균을 내는 부분의 코드다(워치 앱 LiveCard.kt). 값이 없는 구간(NaN)은 평균에서 빼기 때문에, 오래 빈 곳 양옆의 값이 바닥 쪽으로 끌려 내려가지 않는다.

// 구간마다 앞뒤를 가우시안(σ = sigmaBuckets 구간, ±3σ까지)으로 가중 평균한다.
private fun gaussian(values: FloatArray, sigmaBuckets: Float): FloatArray {
    if (sigmaBuckets < 0.5f) return values
    val reach = (sigmaBuckets * 3).toInt()
    val weights = FloatArray(reach + 1) { d -> exp(-0.5f * (d / sigmaBuckets) * (d / sigmaBuckets)) }
    return FloatArray(values.size) { i ->
        if (values[i].isNaN()) return@FloatArray Float.NaN
        var sum = 0f
        var total = 0f
        for (j in maxOf(i - reach, 0)..minOf(i + reach, values.lastIndex)) {
            if (values[j].isNaN()) continue
            val w = weights[abs(i - j)]
            sum += values[j] * w
            total += w
        }
        sum / total
    }
}

그리는 순서는 뒤에서부터 점 격자 → 걸음 막대(회색 그라데이션) → 칼로리 시간별 막대(녹색, 맨 위 고도 띠 자리) → 스트레스 칸(그래프 아래 따로) → 땀 띠 → 고도 점선(주황) → 땀 선(파랑) → 심박 선(빨강)이고, 최고점 숫자와 지금 값 점은 맨 위에 찍는다. 세로 눈금은 항목마다 그날 최저~최고라 공통 눈금이 없다. 그래서 눈금 숫자는 쓰지 않고 지금 값만 아래 통계줄에 쓴다.

몇 가지 규칙이 더 있다.

  • 이번 시간 그래프. 0~6시는 자느라 변화가 없어 하루 그래프의 1/3이 늘 바닥에 깔려 있었다. 그래서 0시~18시에는 매시 정각부터 지금까지만 한 화면 가득(1분 구간) 보여 주고, 18시부터는 0시~지금 하루 그래프로 바뀐다.
  • 값이 없는 구간. 워치를 벗었거나 밤에 안 쟀으면 선을 바닥(0)으로 부드럽게 내리고 다음 값에서 올라온다. 선은 늘 0시(정각)부터 지금까지 양끝이 이어져 있다. 바닥 선과 실제 값은 4.5px 띄워 “측정 없음”과 그날 최저값이 구분된다.
  • 고도 띠. 칸 맨 위 15%는 고도 전용이다. 다른 선은 그 아래에서만 그려 겹치지 않고, 변화가 8m보다 작으면 8m 폭으로 보여 잡음이 크게 보이지 않는다.
  • 갱신. 30초마다 다시 그린다. 화면이 켜져 있는 동안은 2초마다 그리는데, 이때는 진행 중인 분의 값(최근 60초 심박·땀)을 더하고 선 끝 12%는 평균을 덜 먹여 끝이 살아 움직인다.
워치가 스스로 찍은 하루 그래프 (2026년 10월 16일)
10월 16일 밤 11시 59분, 워치가 스스로 찍은 하루 그래프. 회색은 걸음, 빨강은 심박, 파랑은 땀(그날 저녁부터 측정 시작), 주황 점선은 고도, 녹색 막대는 칼로리. 스트레스 칸은 당시 보라 계열이었다.

데이터는 어디서 오나

항목출처비고
걸음 숫자WFF [STEP_COUNT]삼성 헬스 걸음과 같은 값. 그래프 모양은 안드로이드 걸음 센서를 1분마다 읽은 차이로 그린다
심박Samsung Health Sensor SDK 연속 심박(1초)정상 상태인 초만 분마다 평균, 그래프는 5분 평균
땀같은 SDK의 EDA 연속 측정(피부 전도도 µS, 1초)정상 상태인 점만 분마다 평균. 측정값 그대로 그린다
고도기압계 + 날씨 서버의 시간별 해면기압 + GPS 보정아래 설명
칼로리걸음과 심박으로 추정걸음 × 0.0004 × 몸무게와 심박 여유율로 낸 MET 중 큰 값. 몸무게·나이는 앱에서
스트레스걸음·심박·땀으로 추정아래 설명. 삼성 스트레스와 같은 값이 아니다
나침반회전 벡터 센서화면이 켜져 있을 때만 센서를 켠다. 1° 넘게 바뀔 때만 보내고 페이스가 1초 애니메이션으로 잇는다
배터리, 날씨WFF [BATTERY_PERCENT], [WEATHER.*]컴플리케이션을 고르지 않아도 늘 보인다

고도: 기압계와 날씨 서버

울트라 인포보드의 고도 값은 삼성 전용 센서(SemSensorManager의 ALTITUDE)에서 온다. 이걸 그대로 가져오려고 꽤 파 봤는데 안 된다. 필요한 com.samsung.permission.SSENSOR 권한이 signature|privileged, 즉 삼성이 서명한 시스템 앱 전용이라 일반 앱은 받을 수 없다. 값을 쓰는 프로바이더도 삼성 페이스 패키지만 읽게 막혀 있다(“not allowed package”). hidden_api_policy를 풀어도 센서 서비스가 “NO SSENSOR permission”으로 거부했다. 결론은 직접 계산.

기압으로 낸 고도는 날씨로 기압이 바뀌면 하루에도 20m 넘게 틀어진다. 그래서 세 가지를 합쳤다. 첫째, 1시간마다 Open-Meteo에서 어제~내일 시간별 해면기압(pressure_msl)을 받아 그 시각의 기준으로 쓴다(API 키 없음, 위치는 소수 둘째 자리로 줄여 보냄). 둘째, 워치 기압계와 모델의 차이는 GPS 고도로 보정해 기억한다. 이 워치는 모델보다 약 1.5hPa(약 13m) 낮게 읽었다. 셋째, 가만히 있는 동안(분당 5보 미만, 기압 변화 0.15hPa 이하)은 고도를 붙잡아 두고, 움직일 때만 기압 변화를 고도로 반영한다. 그 사이 기압 변화는 날씨로 본다. 3층과 1층을 오갈 때마다 고도가 올라가던 문제가 이걸로 잡혔다.

고도를 붙잡는 부분이다(Altitude.kt). 걸음이 있거나 기압이 빠르게 바뀐 분만 “움직였다”고 보고, 나머지 분은 그 사이 기압 변화를 모두 날씨로 돌린다. 모델 고도와 벌어진 차이(offset)는 6시간 시간 상수로 천천히 모델 쪽으로 돌아간다.

fun track(samples: List<Sample>, seaLevel: SeaLevel): List<Point> {
    val points = ArrayList<Point>(samples.size)
    var last: Sample? = null
    var lastModel = 0f
    var offset = 0f
    for (s in samples) {
        val pressure = s.pressure ?: continue
        val sea = seaLevel.at(s.minute)      // 그 시각의 해면기압(날씨 모델 + 기압계 차이)
        val model = of(sea, pressure)        // SensorManager.getAltitude(해면기압, 기압)
        val prev = last
        if (prev != null) {
            val minutes = s.minute - prev.minute
            if (minutes <= MAX_GAP_MINUTES) {
                // 움직였으면 기압이 바뀐 만큼만, 아니면 고도를 그대로(모델 변화를 상쇄)
                offset -= if (moved(prev, s, minutes)) of(sea, prev.pressure!!) - lastModel else model - lastModel
            }
            offset *= exp(-minutes / PULL_MINUTES)   // 6시간 상수로 모델 쪽으로 되돌림
        }
        points += Point(s.minute, model + offset, offset)
        last = s
        lastModel = model
    }
    return points
}

// 걸음이 있었거나 기압이 빠르게 바뀌었으면 "움직였다"
private fun moved(a: Sample, b: Sample, minutes: Long): Boolean {
    val steps = if (a.stepTotal == null || b.stepTotal == null) 0L
    else if (b.stepTotal >= a.stepTotal) b.stepTotal - a.stepTotal else b.stepTotal
    return steps >= WALKING_STEPS_PER_MINUTE * minutes || abs(b.pressure!! - a.pressure!!) >= FAST_HPA_PER_MINUTE * minutes
}

삼성 고도가 어떤 기준으로 계산되는지는 로그로 볼 수 있다. 삼성 페이스가 보이는 동안 센서 허브가 이런 줄을 남긴다.

$ adb logcat -d | grep ALTITUDE_RET
A(23/-1000)(P101746)(SLP102029)(21/2/9062)
  → 고도 23m, 기압 1017.46hPa, 기준 해면기압 1020.29hPa, 기준을 받은 뒤 9062초

삼성도 기준을 한 번 받으면 그대로 쓰기 때문에, 가만히 있어도 날씨로 기압이 바뀌면 삼성 고도도 바뀐다(한 시간 반 동안 28m → 24m). 우리 값과 삼성 값이 같이 움직이는 것을 확인하고, 삼성이 받은 기준에 맞춰 차이를 -1.315hPa로 정했다.

스트레스: 삼성 값은 못 가져온다

가장 오래 헤맨 부분이다. 순서대로 적는다.

  1. 심박 변이(HRV)의 RMSSD. 처음에는 SDK가 주는 박동 간격(IBI)으로 RMSSD를 냈다. 값이 85~150ms로 교과서(20~60ms)보다 훨씬 높아 이상했는데, 알고 보니 이 워치의 IBI가 64ms 격자로 끊겨 온다(512·1220·1732…). 연속 차이가 0·64·128ms로만 나오니 RMSSD가 몸 상태와 무관하게 격자 잡음 바닥에 모인 것이다. 하루 825개 분값의 85%가 90~120ms였다. 교과서 값이 격자 한 칸보다 작으니 복원할 수 없다. 포기.
  2. 삼성 헬스 스트레스 컴플리케이션. 페이스에 숨은 슬롯을 두고 삼성 스트레스 제공자를 붙여 접근성 서비스로 글자를 읽으려 했다. 그런데 삼성 헬스 APK 매니페스트의 SUPPORTED_TYPES가 ICON·SMALL_IMAGE·SHORT_TEXT뿐이고 글자는 늘 “스트레스”다. 아이콘과 이름만 있는 바로가기라 값(숫자·단계)을 어떤 타입으로도 주지 않는다. 헬스 커넥트에도, 폰의 삼성 헬스 데이터 SDK에도 스트레스 타입은 없다.
  3. 심박만으로 식 만들기. 안정 심박 대비 상승분, 1초 심박의 고름(표준편차), 운동 뒤 회복 같은 재료로 몇 번 식을 바꿔 봤다. 결국 심박 선의 1차 변환이라 두 선이 같은 모양이었다. “더 이상 수학 계산으로 만든 값은 못 믿겠다”가 솔직한 결론이었다.
  4. 땀(EDA) 발견. 워치 센서 목록(dumpsys sensorservice)에 “Samsung EDA Raw Sensor”가 있었다. 안드로이드 센서로 직접 여는 길은 SSENSOR 권한이라 막혀 있지만, Health Sensor SDK 1.4.1의 EDA_CONTINUOUS 트래커로는 받을 수 있었다. READ_ADDITIONAL_HEALTH_DATA 권한을 선언하고 부여하니 1초에 한 점씩 피부 전도도(µS)가 들어왔다. 앉아 있을 때 0.75~1.2µS. 같은 권한으로 PPG 초록 원시 파형(25Hz)도 온다.

피부 전도도는 땀샘이 열리면 오른다. 운동으로 땀이 날 때도, 긴장이나 놀람으로 교감신경이 땀샘을 열 때도 오른다. 그래서 땀 선은 계산 없이 측정값 그대로 파란 선으로 그리고, 스트레스 추정은 걸음·심박·땀 세 재료의 관계로 따로 낸다.

자극 = 100 × √(H × E) × (1 − D)

H = (심박 − 낮 안정 중앙값) / 15                        … 0~1
E = 땀 수준(바탕 대비 상승)과 짧은 땀 반응 횟수의 평균   … 0~1
D = 분당 걸음 / 80 (멈춘 뒤 10분마다 반)                 … 0~1, 운동을 걸러 냄

상태: 자극이 지금 값보다 크면 차이의 30%만큼 바로 오르고,
      작으면 분마다 0.97배 (반감기 약 23분)

같은 식을 Kotlin으로 쓰면 이렇다(StressEstimate.kt). 위의 상수 다섯 개가 조정 손잡이라, 며칠 데이터를 보고 여기만 손보면 된다.

const val HEART_FULL_BPM = 15f      // 기준보다 15bpm 높으면 H = 1
const val SWEAT_LEVEL_FULL = 0.3f   // 바탕보다 30% 높으면 땀 수준 = 1
const val RESPONSES_FULL = 4f       // 짧은 땀 반응이 분당 4번 많으면 튐 = 1
const val RISE = 0.3f               // 오를 때는 차이의 30%만큼 바로
const val DECAY = 0.97f             // 내릴 때는 분마다 0.97배

fun heartPart(bpm: Float, base: Baselines): Float =
    ((bpm - base.heartBpm) / HEART_FULL_BPM).coerceIn(0f, 1f)

fun sweatPart(microsiemens: Float, responses: Float, base: Baselines): Float {
    val span = maxOf(base.sweat * SWEAT_LEVEL_FULL, SWEAT_LEVEL_MIN_MICROSIEMENS)
    val level = ((microsiemens - base.sweat) / span).coerceIn(0f, 1f)
    if (responses.isNaN()) return level
    val bursts = ((responses - base.responsesPerMinute) / RESPONSES_FULL).coerceIn(0f, 1f)
    return (level + bursts) / 2f
}

// 이번 분의 자극(0~100). damping 은 걸음으로 본 움직임 D(0~1)
fun impulse(bpm: Float, microsiemens: Float, responses: Float, damping: Float, base: Baselines): Float =
    100f * sqrt(heartPart(bpm, base) * sweatPart(microsiemens, responses, base)) * (1f - damping.coerceIn(0f, 1f))

// 다음 분의 상태: 빠르게 오르고 천천히 내려온다. 심박이나 땀이 없는 분(null)은 내려가기만
fun next(previous: Float, impulse: Float?): Float = when {
    impulse == null -> previous * MISSING_DECAY
    impulse > previous -> previous + (impulse - previous) * RISE
    else -> previous * DECAY
}

심박과 땀을 더하지 않고 곱하는 것이 핵심이다. 걸음 없이 둘이 같이 오를 때만 커진다. 커피로 심박만 오르거나 더운 방에서 땀만 나면 작고, 걷는 동안과 직후는 걸러진다. 긴장은 몇 초짜리 땀 반응(1~3초에 오르고 몇 초에 걸쳐 내려오는 봉우리)이 얼마나 잦은지에 잘 드러나는데 1분 평균에서는 지워지므로, 1초 점에서 따로 세어 재료에 넣었다. 기준(안정 심박, 땀 바탕)은 최근 26시간 중 낮 8~22시에 가만히 있던 분으로 낸다. 삼성 스트레스와 같은 값은 아니고 경계값은 첫 추정이라 며칠 더 쓰며 맞출 생각이다.

그리는 방법도 여러 번 바꿨다. 선으로 그리니 심박·땀·스트레스가 오른쪽 끝 한곳에 모였다(셋 다 “오늘 높은 편”이면 같은 높이에 온다). 그래서 그래프 아래 31px 칸을 따로 두고 삼성처럼 꺾은선으로 그리되 선 아래를 단계 색으로 꽉 채운다. 높이는 그날 최고값에 맞춰 늘리고(2.5배까지), 색은 값 그대로의 고정 눈금이라 같은 값은 날마다 같은 색이다. 경계는 삼성의 편안함·낮음·보통·높음과 같은 25·50·75.

색은 보라 한 계열 → 초록~빨강 → 다섯 단계 → 하늘·남보라·보라·주황·빨강 → 주황 한 계열 → 하늘·연두·오렌지·보라 → 채도 낮춘 네 색 → 페어리 블루·피스타치오·망고·체리 핑크 → 연보라~자두까지 열 번 넘게 바꿨다. 하루 대부분인 편안 단계가 가장 진한 색이면 칸이 덩어리로 보이고, 파랑·초록은 땀·칼로리 색과 겹치고, 밝기 순서가 단계 순서와 어긋나면 읽기 어렵다는 것을 하나씩 배웠다. 지금은 황토 계열(연황토 #CFA86C → 황토 #BE8634 → 진황토 #96601A → 흑갈 #633C10)로, 높을수록 어둡다. AOD에서는 검정에 묻히지 않게 반대로 높을수록 밝다.

스트레스 단계 색 비교: 보라 계열과 황토 계열
색을 고를 때 만든 비교 그림. 위(밝은 바탕)는 이전 보라 계열과 새 황토 계열, 아래(검정 바탕)는 AOD용. 맨 위 작은 점 다섯은 걸음·칼로리·고도·땀·심박 색이라 겹치지 않는지 같이 본다.

하루 스냅샷과 폰 앱

매일 23:59:50에 워치가 그날 0시~23:59 데이터로 두 가지를 만든다. 워치 화면 모양 그림(498×498)과 분 단위 기록(day.csv: 분, 걸음, 고도, 땀, 심박, 칼로리, 스트레스)이다. Wear OS Data Layer로 폰에 보내는데, 폰이 멀리 있으면 Google Play 서비스가 들고 있다가 이어질 때 전달한다. 폰 앱과 워치 앱의 applicationId와 서명이 같아야 Data Layer가 이어진다.

실제 화면 캡처도 넣고 싶었다. 갤럭시 워치는 홈+뒤로 가기로 찍은 캡처를 폰 갤러리로 자동으로 보내 주지만 일반 앱은 키를 누를 수 없다. 그래서 접근성 서비스의 GLOBAL_ACTION_TAKE_SCREENSHOT으로 23:59:20에 시스템 캡처를 일으킨다. 이 캡처는 삼성 경로가 아니라 AOSP 경로(Pictures/Screenshots/wear_screenshot.png, 매번 덮어씀)로 떨어지고 삼성이 폰으로 보내 주지 않으므로, 3.5초 뒤 그 파일을 읽어 스냅샷에 같이 싣는다. 한 가지 함정: 앱을 am force-stop으로 강제 종료하면 안드로이드가 그 앱의 접근성 서비스를 꺼 버린다. 실제로 하루치 캡처를 놓쳤다. 업데이트 설치(adb install -r)로는 꺼지지 않는다.

폰 앱 뷰어 하루 보기 (2026년 10월 17일)
폰 앱의 하루 보기. 워치와 같은 그리기 코드를 옮겨 와 모양이 같고, 아래에 항목별 최고·최저와 시각이 나온다. 월 보기는 날마다 한 점, 년 보기는 달마다 한 점이다.

삼성 런타임에서 배운 것

문서에 없어서 로그와 실험으로 알아낸 것들이다. 다음에 고칠 때 꼭 알아야 한다.

  • 슬롯 순서가 설정 키다. 꾸미기에서 고른 제공자를 slotId가 아니라 문서 안 슬롯 순서로 기억한다. 중간에 슬롯을 넣으면 설정이 한 칸씩 밀린다. 새 슬롯은 항상 맨 뒤에.
  • 기본 제공자는 슬롯이 처음 생길 때만 적용된다. 이미 설정된 슬롯은 다시 설치해도 바뀌지 않는다.
  • BoundingArc를 0°~360°로 잡으면 대부분 잘린다. 끝 각도를 359.9로 하면 한 바퀴가 다 그려진다.
  • 둥글게 놓을 글자는 TextCircular를 쓴다. PartText의 angle 회전은 회전하는 묶음 안에서 글자가 엉뚱한 자리에 겹친다. 화면은 반지름 약 245 밖이 가려지므로 큰 글자는 더 안쪽에 둔다.
  • AOD에서 손목을 들어도 기기는 깨지 않는다. 삼성 AOD는 손목 들기(wrist_tilt_gesture)가 오면 기기를 깨우지 않고 화면만 약 5초 켠다. PowerManager.isInteractive는 false이고 SCREEN_ON 방송도 없다. 앱이 “화면이 보이는지”를 알려면 Display.getState() == STATE_ON을 봐야 한다. 나침반이 손목을 들면 멈춰 있던 원인이 이것이었는데, 세 번 잘못 짚은 뒤 시스템 로그에서 찾았다.
  • 사진 이미지에 tintColor가 듣지 않는다. 모드별 색이 필요하면 그림을 따로 준비한다.
  • 컴플리케이션 갱신은 1초 간격까지 통과한다. 속도 제한기가 있지만 나침반의 1초 갱신은 모두 전달됐다. Transform 안의 Animation도 쓸 수 있다.
  • 앱을 덮어 설치하는 순간 Wear Services가 죽을 수 있다. ConcurrentModificationException으로 죽고, 3초 뒤 우리 앱이 죽은 서비스에 그래프를 보내다 DeadObjectException으로 같이 죽는다. 둘 다 저절로 다시 뜨고 접근성 서비스도 유지된다. 설치 뒤 adb logcat -d -b crash 로 확인한다.
  • 화면 캡처로 AOD는 찍히지 않는다. 디스플레이 오프로드라 검은 화면만 나온다. 무선 충전기 위에서는 회전 벡터 센서 값이 나오지 않는다.

손목 들기 판정은 결국 이렇게 됐다(StressService.kt, 로그 줄은 뺐다). 화면 켜짐 방송이 아니라 디스플레이 상태를 직접 보고, 같은 판정으로 나침반 센서와 그래프의 2초 갱신을 함께 켜고 끈다.

// "화면이 보이는지"는 isInteractive 가 아니라 디스플레이 상태로 본다.
// AOD 에서 손목을 들면 기기는 잠든 채(isInteractive = false) 화면만 약 5초 켜진다.
private fun updateDisplay() {
    val state = getSystemService(DisplayManager::class.java)
        .getDisplay(Display.DEFAULT_DISPLAY)?.state ?: Display.STATE_UNKNOWN
    val on = state == Display.STATE_ON || getSystemService(PowerManager::class.java).isInteractive
    if (on != displayOn) {
        displayOn = on
        handler.removeCallbacks(fastTick)
        if (on) {
            MainComplicationService.requestUpdate(this)    // 켜지는 순간 바로 한 번 그리고
            handler.postDelayed(fastTick, FAST_REDRAW_MS)  // 켜져 있는 동안 2초마다
        }
    }
    if (on) Compass.start(this) else Compass.stop()
}

개발 환경과 설치

항목값
기기Galaxy Watch Ultra 2 (SM-L715N), Wear OS 6 (Android 17, SDK 37), 498×498 원형. 땀(EDA)·연속 심박은 울트라2의 센서와 Samsung Health Sensor SDK로 받는다
빌드Gradle 9.6.0, AGP 9.4.1, Kotlin 2.2.10, JDK는 Android Studio JBR
SDKSamsung Health Sensor SDK 1.4.1 (aar), Wear OS Data Layer
워치 연결무선 디버깅(adb). IP와 포트가 자주 바뀌고 워치가 잠들면 끊긴다
서명PC의 디버그 키. 폰 앱도 같은 키로 설치해야 Data Layer가 이어진다
.\gradlew.bat :wear:assembleDebug :watchface:assembleDebug
adb install -r wear\build\outputs\apk\debug\wear-debug.apk
adb install -r watchface\build\outputs\apk\debug\watchface-debug.apk

# 연결이 자주 끊길 때는 파일을 먼저 옮기고 워치 안에서 설치
adb push wear\build\outputs\apk\debug\wear-debug.apk /data/local/tmp/infoface.apk
adb shell pm install -r -t /data/local/tmp/infoface.apk

무선 adb가 제일 성가셨다. 충전 중이라도 화면이 꺼지면 PC 쪽 연결이 offline이 되고 adb는 스스로 되살리지 않는다. 39MB 설치가 20~40초 걸리는데 화면 꺼짐 15초로는 두 번 끊겼다. 지금은 워치에 cw_disable_wifimediator 1 을 넣어 블루투스 연결 중에도 Wi-Fi를 끄지 않게 하고, PC에는 10초마다 mDNS로 워치를 찾아 다시 붙는 스크립트를 로그온 작업으로 걸어 두었다. 설치할 때만 screen_off_timeout 을 3분으로 늘렸다가 되돌린다. 그래도 손목에서 빼고 충전기에도 없으면 1분 뒤 완전히 잠들어 PC 쪽에서는 막을 수 없다.

새 워치에 설치할 때 빠뜨리기 쉬운 것이 Health Sensor Service 개발자 모드다. 설정 → 애플리케이션 → Health Sensor Service에서 제목을 약 10번 탭하면 개발자 모드 버튼이 나온다. 이게 없으면 심박·땀 측정이 SDK_POLICY_ERROR로 실패한다. 접근성 서비스(23:59 화면 캡처)는 앱 설치 뒤에 켜야 바인드된다.

한계와 남은 일

  • 땀은 운동과 긴장을 가르지 못하고, 바탕값은 날씨·피부 상태·착용 압력에 따라 달라진다. 1초 연속 측정이라 배터리를 더 쓴다. 워치를 벗어 충전기에 올려도 상태가 정상(0)으로 와서 0.3~0.6µS가 기록되는 것도 걸러야 한다.
  • 스트레스 추정의 경계값은 첫 추정이다. 걸음이 없는 운동(자전거·근력)은 걸음으로 못 거르고, 좋은 흥분과 나쁜 긴장은 구분하지 못한다. PPG 원시 파형에서 박동을 직접 찾아 HRV를 내는 길은 남아 있다.
  • 고도는 날씨 모델 자체가 ±0.5~1hPa(±4~8m) 틀릴 수 있다. 인터넷이 끊기면 마지막 자료를 쓰다가 6시간이 넘으면 고정 해면기압으로 돌아간다.
  • 걸음 그래프 모양은 워치 센서 값이라 폰으로 걸은 걸음은 막대에 반영되지 않는다(숫자는 삼성 값). 칼로리도 걸음+심박 추정이라 삼성 활동 칼로리와 다르다.
  • 폰 앱은 칼로리를 아직 그리지 않고, 스트레스 칸 색도 워치보다 한 세대 전(보라 계열)이다.
  • 오래 쓰려면 전용 서명 키로 릴리스 빌드를 만들어 두어야 한다.

혈중 산소와 주변 소음(청력)도 한때 넣었다가 뺐다. 혈중 산소는 가만히 있을 때만 성공하고 1분 간격이라 배터리를 더 썼고, 소음은 안드로이드 14의 백그라운드 마이크 제한 때문에 잴 때마다 마이크 표시가 떴다. 그래프에 선이 일곱 개가 되니 시선도 분산됐다. 지금은 여섯 항목이고, 이 정도가 손목 위 400×294 픽셀에 들어가는 한계인 것 같다.

소스 코드

워치 페이스(WFF XML), 워치 앱, 폰 앱의 전체 소스다. 빌드 산출물과 개인 설정은 뺐고, 삼성 Health Sensor SDK의 aar 파일은 삼성 약관상 넣지 못해 받는 방법을 안에 적어 두었다. 빌드와 설치 방법은 묶음 안의 README.md에 있다.

  • 필요한 것: Android Studio(Gradle 9.6, AGP 9.4, Kotlin 2.2.10), Samsung Health Sensor SDK 1.4.1 aar, 워치의 개발자 옵션과 Health Sensor Service 개발자 모드
  • 빌드: gradlew :wear:assembleDebug :watchface:assembleDebug :mobile:assembleDebug 뒤 adb로 워치에 설치
  • 개인 학습·취미 용도로 자유롭게 쓰고 고쳐도 된다

참고 자료

댓글 남기기