지난 5월, STSPIN32G4로 BLDC 모터를 FOC(자속 기준 제어)로 돌리는 과정을 짧은 영상 세 편으로 남겼다. Motor Control Workbench로 프로젝트를 만드는 전체 흐름, 생성된 drive_parameters.h에서 실제로 손대게 되는 항목, 그리고 Motor Pilot의 프로파일러로 모터 시정수를 재는 방법이다. 화면 녹화라 말로 풀지 못한 부분이 많았다. 그때 느낀 점과 몇 달 더 써 보며 알게 된 것들을 여기에 모아 둔다.
Workbench가 만들어 준 프로젝트를 STM32CubeMX로 연 모습. STSPIN32G4 안의 MCU는 STM32G431VBTx다. 초록으로 잡힌 핀이 모터 제어에 쓰는 PWM, 션트 전류, 버스 전압, OPAMP 핀이고, 나머지는 비어 있어 UART나 I2C를 더 붙일 수 있다.
왜 STSPIN32G4인가
예전에는 BLDC를 돌리려면 홀 센서나 엔코더를 읽고, 타이머 인터럽트에서 전류(轉流) 순서를 바꾸고, 가감속 궤적을 직접 계산했다. 되기는 되지만 모터를 바꿀 때마다 그 코드를 다시 손봐야 했고, 센서리스나 FOC 같은 것은 엄두를 내지 못했다. 영상 설명에 “언제까지 타이머 돌려서 경로 궤적 구하고 엔코더 읽고 예전 방식으로 할 것인가”라고 쓴 것은 그 답답함이었다.
STSPIN32G4는 STM32G431(Cortex-M4, 170 MHz) 코어와 3상 게이트 드라이버, 전원 레귤레이터를 한 패키지에 넣은 칩이다. 바깥에 MOSFET 여섯 개와 션트 저항 세 개만 붙이면 3상 인버터가 된다. 여기에 ST가 무료로 주는 Motor Control SDK(MCSDK)를 쓰면 전류 루프, 속도 루프, 위치 루프, 센서리스 관측기까지 이미 들어 있는 펌웨어를 설정만으로 받아 쓸 수 있다. 모터 드라이버를 “짜는” 일이 “설정하고 다듬는” 일로 바뀐다. 쓰는 도구는 다섯 가지다.
도구
역할
MotorControl Workbench 6.4.x
보드, 모터, 제어 방식을 고르고 펌웨어 프로젝트를 생성한다
STM32CubeMX
Workbench가 넘겨준 .ioc로 핀과 주변장치를 확정하고 코드를 생성한다
IAR Embedded Workbench (또는 STM32CubeIDE)
빌드하고 ST-LINK로 다운로드, 디버그한다
Motor Pilot
UART로 보드와 연결해 기동과 정지, 속도 지령, 레지스터 읽기와 쓰기, 파형 보기
Motor Profiler (Motor Pilot 안)
모터를 실제로 돌려 Rs, Ls, Ke, 관성, 마찰을 측정한다
영상 1. Workbench로 설정하기, 전체 흐름
2026년 5월 3일. Workbench에서 보드와 모터를 고르고 FOC 프로젝트를 만들어 IAR에서 빌드, 다운로드까지 (7분 27초).
7분짜리 영상이지만 흐름은 단순하다. 보드와 모터를 고르고, 요약 화면에서 세부 설정을 확인하고, 프로젝트를 생성해 빌드하면 끝이다. 영상에 나온 순서대로 적으면 이렇다.
단계
화면
내용
1
Workbench 홈, BOARD
보드 목록에서 EVSPIN32G4를 고른다. 170 MHz, 버스 10~75 V, 정격 20 A.
2
MOTOR
SM-PMSM 24 V 모터. 극쌍 수 2, 최대 4 krpm, 정격 1.8 Apk. 실제 모터 값은 여기서 바꾸거나, 나중에 프로파일러로 잰 값으로 고친다.
3
Go To Summary
FOC 블록도가 나온다. 전원 24 Vdc, 구동 모드 Speed, PWM 16 kHz(뒤에 20 kHz로), 버스 전압 감지 10~28.8 V, 온도 100 ℃, 전류 감지 45 Apk, 속도 감지 Observer+PLL(센서리스).
4
FOC Wizard
블록을 누르면 세부 설정. Motor(Rs, Ls, B-EMF 상수, 관성, 마찰, 최대 응용 속도), PWM Generation(주파수, 데드타임, DRV1 핀 매핑 GHS/GLS), Current Sensing(3션트, 내부 OPAMP 7.33배, 샘플링 시간), Speed Sensing Config(센서리스 기동 프로파일, 기동 종료 조건, Rev-up에서 FOC 전환), Driver Protection, Drive Settings, Application Configuration(FreeRTOS, DAC Debug, Motor Profiler).
5
Save, Generate the project
STM32CubeMX 6.13과 X-CUBE-MCSDK 6.4.2로 생성한다. 툴체인은 IAR EWARM.
6
STM32CubeMX
생성된 .ioc를 열면 STM32G431VBTx LQFP100 핀 배치가 나온다. UART, I2C 같은 추가 주변장치는 여기서 붙이고 Generate Code.
7
IAR
구버전 프로젝트 변환을 수락하고, Options에서 최적화와 디버거(ST-LINK, SWD)를 맞춘 뒤 Build, Download and Debug. while 루프에 테스트 카운터를 넣고 Live Watch로 도는지 본다.
영상 중간에 ST-LINK가 “No MCU device found”를 내는 장면이 있다. 보드 전원과 SWD 배선을 다시 확인하고 재시도하면 붙는다. 처음 한 번은 꼭 겪는 일이라 일부러 잘라내지 않았다.
Workbench는 코드 생성기이지 편집기가 아니다. Workbench에서 바꾼 설정은 전부 헤더 세 개로 떨어진다. 모터 값은 pmsm_motor_parameters.h, 보드 쪽(션트, 증폭, 전압 분압, 데드타임)은 power_stage_parameters.h, 제어 루프와 보호와 기동은 drive_parameters.h다. 이걸 알고 나면 소소한 튜닝은 Workbench를 다시 열지 않고 헤더만 고치면 된다. 다만 Workbench에서 다시 생성하면 그 수정이 덮어써진다. 오래 갈 값은 Workbench(.stwb6)와 CubeMX(.ioc)에도 같이 맞춰 두거나 USER CODE 영역에 적어 둬야 한다. 한 번 당하고 나서야 알았다.
이 무렵 BLDC로 위치 제어를 하고 있었다. 프로파일러로 모터 값을 맞추고 PI 게인을 튜닝한 다음에도, 쓰다 보면 반복해서 고치는 항목은 셋이었다. AD 샘플링 시간, 최대 회전수, 그리고 헌팅이 심할 때 바꿔 보는 PWM 캐리어 주파수다. 영상은 그 파일의 어디에 무엇이 있는지를 위에서부터 훑은 것이다. 영상에 보이는 값과 함께 정리한다.
버스 전압과 상전류의 AD 샘플링 시간(클록 수). 전류 샘플링은 PWM 가운데의 짧은 창에서 이뤄지므로 길게 잡으면 쓸 수 있는 최대 듀티가 줄고, 짧게 잡으면 노이즈를 탄다.
영상 끝에는 main.c도 잠깐 나온다. 부트로더를 쓰려고 SCB->VTOR로 벡터 테이블을 옮기는 세 줄, 그리고 초기화 순서다. ADC, 비교기(COMP), CORDIC, DAC3(과전류 기준 전압), OPAMP, TIM1을 먼저 올리고 그다음에 MX_MotorControl_Init()을 불러야 한다. 순서가 틀리면 모터 제어가 아예 뜨지 않는다. Motor Pilot이 쓰는 USART1은 그대로 두고, 상위 장치와 주고받는 명령 프로토콜은 USART3에 따로 붙였다.
영상 3. Motor Profiler로 모터 시정수 재기
2026년 5월 25일. Motor Pilot의 프로파일러로 Rs, Ls, Ke와 관성을 재는 과정 (3분 36초).
FOC의 품질은 결국 모터 모델 값이 얼마나 맞느냐에 달려 있다. 센서리스 관측기는 “이 전압을 걸었더니 이 전류가 흘렀으니 역기전력은 이만큼일 것이다”라고 Rs, Ls로 역산하는 장치다. 데이터시트 값이 없거나 믿기 어려운 모터라면 프로파일러로 직접 재는 것이 가장 빠르다. 순서는 이렇다.
Motor Pilot을 열고 Load UI로 프로파일러 화면을 불러온다. 이때 FOC 모드로 들어간다.
펌웨어의 UART 속도와 Pilot의 연결 속도를 맞춘다. 너무 느리지만 않으면 된다.
연결되면 교정(calibration)을 할지 묻는다. 교정 또는 No를 고른다.
처음 붙으면 보통 “Firmware fault detected”가 빨갛게 뜬다. Ack Faults로 에러를 지운다.
극쌍 수(Pole pairs), 최대 속도, 최대 전류, 저전압과 과전압 한계(영상에서는 12 < 23 < 31 V), 자기 구조(SM-PMSM)를 넣고 Start profile을 누른다.
모터가 몇 단계로 돌면서 진행률이 올라간다. 100 %가 되면 Rs, Ls, Ke, 관성, 마찰이 채워지고, Save로 Workbench 모터 파일에 저장한다.
프로파일링이 진행 중인 화면. 아래 막대가 진행률이고, 끝나면 Electrical model(Rs, Ls, Ke)과 Mechanical model(마찰, 관성)에 값이 채워진다. 오른쪽 아래 Errors 표시가 모두 꺼져 있어야 한다.
28 % 부근에서 진행이 멈춘 채 모터가 그냥 돌기만 하거나 정지해 있으면 타임아웃이다. 극쌍 수, 속도, 전류를 적당히 고쳐 다시 한다. 그래도 안 되면 소스의 Rs, Ls 같은 시정수를 조금 손봐야 한다.
영상 3의 자막에서
왜 그런가 하면, 프로파일러도 결국 같은 펌웨어 위에서 돈다. 처음 넣은 모터 값이 실제와 너무 다르면 관측기가 속도를 못 잡고, 프로파일러는 다음 단계로 못 넘어간다. 그래서 100 %가 안 되면 회로 쪽 시정수(션트 값, 증폭률, 버스 전압 분압이 실제 보드와 맞는지)와 모터 기본 파라미터(극쌍 수, 대략의 Rs와 Ls, 최대 속도)를 먼저 의심해야 한다. 특히 최대 속도를 실제보다 낮게 적으면 “The measurement is taking too much time”으로 멈춘다. 전기 시정수 τ = Ls/Rs가 수백 µs인 작은 모터는 AD 샘플링과 전류 루프가 그 안에서 따라가야 하므로 PWM 주파수와 샘플링 시간도 같이 본다.
직접 만든 보드로 옮기며 배운 것
EVSPIN32G4로 돌려 본 뒤 같은 칩으로 보드를 직접 그렸다. 참고 보드와 내 회로를 비교하며 정리한 체크리스트다.
항목
내용
션트 저항
3상 모두 10 mΩ으로 통일했다. 펌웨어의 RSHUNT, CubeMX의 M1_RSHUNT, Workbench 값이 서로 다르면 코드를 다시 생성할 때 되돌아간다. 세 곳을 같이 맞춘다.
SCREF 핀
VDS 단락 보호의 기준 전압. 0.2~2.5 V면 보호가 켜지고 2.9~3.3 V면 꺼진다. 테스트 때 꺼 두고 잊으면 안 된다.
속도 피드백
Observer+PLL 센서리스만 쓰고 엔코더는 커넥터만 남겨 두었다. 나중에 쓰려면 타이머를 엔코더 모드로 잡고 Workbench의 Speed Sensing을 바꿔 다시 생성한다.
Speed feedback fault
모터는 잘 도는데 fault만 뜨면 M1_SS_MEAS_ERRORS_BEFORE_FAULTS를 3에서 25까지 늘려 완화했다. 근본 원인은 Rs, Ls, B-EMF 값의 불일치다.
Pilot에서 속도 바꾸기
Speed Reference 칸만 바꿔서는 보드로 가지 않는다. Target Speed와 Duration을 넣고 Execute Speed Ramp를 눌러야 한다.
안 돌 때
Pilot의 Status와 Errors부터 본다. Start Up Failure는 기동 구역과 전류 한도, Over Current는 배선과 드라이버, Under Voltage는 전원이다. 전류 한도를 잠시 10 A로 올려서 돌면 한도 문제이고, 그래도 안 돌면 모델, 배선, 전원 문제다.
과온
NTC를 달았으면 M1_TEMPERATURE_READING을 켜고 과온 때 PWM을 끄는 처리를 넣는다.
정리
세 영상을 다시 보니 결론은 간단하다. 첫째, Workbench 흐름만 익히면 새 모터도 하루 안에 FOC로 돌릴 수 있다. 둘째, 그 뒤의 모든 튜닝은 헤더 세 개 안에서 끝난다. 셋째, 그 튜닝의 출발점은 프로파일러 100 %다. 모델이 틀리면 게인을 아무리 만져도 안정이 되지 않는다. 다음에는 엔코더를 붙여 위치 제어 정밀도를 올리고, 전류 한도와 발열, 상위 장치와의 명령 프로토콜을 정리한 글을 쓸 생각이다.
갤럭시 워치 울트라2(SM-L715N, Wear OS 6)에 쓸 워치 페이스를 직접 만들었다. 울트라2의 기본 페이스 “울트라 인포보드”의 밝은 느낌은 그대로 두고, 가운데에 오늘 0시부터 지금까지의 걸음·칼로리·고도·땀·심박·스트레스 추정을 한 장의 그래프로 겹쳐 그린다. 이름은 Infoface다. 10월 5일에 시작해 보름 넘게 날마다 고쳐 왔는데, 그 과정에서 알게 된 것들을 잊기 전에 정리해 둔다.
10월 21일 저녁, 실제 갤럭시 워치 울트라2에 올린 모습. 스토어가 아니라 개발자 옵션과 adb로 직접 설치해 날마다 쓰고 있다.
지금 모습(10월 21일 저녁). 바깥 띠는 나침반 링, 왼쪽은 배터리, 가운데는 0시부터 지금까지의 그래프, 그래프 아래 황토색 칸은 스트레스 추정, 맨 아래는 날씨.
무엇을 만들었나
세 모듈로 되어 있다. 워치 페이스는 코드 없이 Watch Face Format(WFF) XML 한 장으로 그린다. 그래프는 워치 앱이 PNG 그림으로 그려서 컴플리케이션(페이스에 값을 넣어 주는 칸)으로 넘겨준다. 폰 앱은 워치가 매일 밤 보내는 하루치 기록을 받아 두고 일·월·년 단위로 보여 주는 뷰어다.
워치 앱. 심박·땀을 재고, 걸음·기압을 읽고, 그래프를 그려 컴플리케이션으로 공급. 매일 밤 하루 스냅샷을 폰으로 전송
Kotlin, Samsung Health Sensor SDK 1.4.1
mobile
폰 앱. 스냅샷을 받아 일·월·년 그래프와 최고·최저를 보여 주는 뷰어
Kotlin, Wear OS Data Layer
10월 5일 첫 모습. 원 3개가 그래프 아래를 차지했고, 배터리는 무지개 그라데이션, 오른쪽에는 일일 활동 숫자가 있었다. 지금과 비교하면 거의 다른 페이스다.
화면 구성
좌표는 기기 실제 픽셀(498×498, 중심 249,249) 기준이다. 다른 해상도의 워치에서는 WFF가 비율대로 늘리거나 줄인다.
위치
내용
데이터 출처
맨 바깥 띠
나침반 링. 5°·10° 눈금, 30°마다 숫자, N(빨강)·E·S(파랑)·W. 링이 돌아 N이 실제 북쪽을 가리키고, 12시의 고정 주황 ▲가 지금 향한 방위다. 누르면 삼성 나침반 앱
워치 앱이 회전 벡터 센서로 방위를 계산해 1초마다 보냄
맨 위
날짜 “10월 21일 수”와 시계 hh:mm
WFF 기본값
가운데 카드 400×294
오늘 0시~지금 그래프. 아래 두 줄에 걸음 수·칼로리·고도 / 스트레스·땀·심박 숫자. 누르면 Infoface 앱 화면
워치 앱이 그린 PNG
왼쪽 가장자리
배터리 게이지. 1% 단위로 채우고, 막대와 아이콘 색이 남은 양에 따라 서서히 바뀐다. “97%”는 테두리를 따라 휘어진 글자
WFF [BATTERY_PERCENT]
아래 가장자리
날씨 “🌙 맑음 15° (21°/10°)”. 아이콘, 날씨 이름(삼성 날씨를 고르면 지역 이름), 현재 기온, (오늘 최고/최저). 테두리를 따라 휘어진 글자
삼성 날씨 컴플리케이션 + WFF [WEATHER.*]
날씨 아이콘은 직접 그렸다. 삼성 날씨 컴플리케이션은 지역 이름과 기온만 주고 그림은 주지 않기 때문이다. WFF의 날씨 상태 값([WEATHER.CONDITION])을 보고 맑음이면 해(밤이면 초승달), 구름 조금이면 해와 구름, 비면 구름과 빗줄기 같은 식으로 도형을 조합해 그린다.
손목을 내리면(AOD) 검정 바탕에 흰 글자로 바뀐다. 날짜·시계·걸음 수 같은 글자는 페이스가 흰색으로 따로 그리면 되지만, 그래프 카드는 앱이 보내는 그림이라 문제였다. 삼성 런타임은 사진 이미지에 tintColor를 곱하기처럼 적용해서 흰색으로 바꿔 주지 않는다. 그래서 워치 앱이 밝은 화면용과 AOD용 카드 두 장을 위아래로 붙인 400×588 그림 한 장을 보내고, 페이스가 모드에 따라 그림을 위아래로 옮겨 절반만 보이게 한다. 슬롯은 자기 영역 안쪽만 그린다는 특성을 이용한 것이다.
배터리 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% 아래에서는 사라져 노랑이 드러난다.
처음에는 측정값을 그대로 꺾은선으로 그렸다. 들쭉날쭉해서 무엇을 봐야 할지 알 수 없었고, 항목마다 줄을 나눠도 봤지만 답답했다. 지금은 “실제 값을 뿌린다기보다 시간에 따라 이렇게 흘러간다”는 느낌으로, 모든 항목을 가우시안으로 넓게 평균하고 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%는 평균을 덜 먹여 끝이 살아 움직인다.
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로 정했다.
스트레스: 삼성 값은 못 가져온다
가장 오래 헤맨 부분이다. 순서대로 적는다.
심박 변이(HRV)의 RMSSD. 처음에는 SDK가 주는 박동 간격(IBI)으로 RMSSD를 냈다. 값이 85~150ms로 교과서(20~60ms)보다 훨씬 높아 이상했는데, 알고 보니 이 워치의 IBI가 64ms 격자로 끊겨 온다(512·1220·1732…). 연속 차이가 0·64·128ms로만 나오니 RMSSD가 몸 상태와 무관하게 격자 잡음 바닥에 모인 것이다. 하루 825개 분값의 85%가 90~120ms였다. 교과서 값이 격자 한 칸보다 작으니 복원할 수 없다. 포기.
삼성 헬스 스트레스 컴플리케이션. 페이스에 숨은 슬롯을 두고 삼성 스트레스 제공자를 붙여 접근성 서비스로 글자를 읽으려 했다. 그런데 삼성 헬스 APK 매니페스트의 SUPPORTED_TYPES가 ICON·SMALL_IMAGE·SHORT_TEXT뿐이고 글자는 늘 “스트레스”다. 아이콘과 이름만 있는 바로가기라 값(숫자·단계)을 어떤 타입으로도 주지 않는다. 헬스 커넥트에도, 폰의 삼성 헬스 데이터 SDK에도 스트레스 타입은 없다.
심박만으로 식 만들기. 안정 심박 대비 상승분, 1초 심박의 고름(표준편차), 운동 뒤 회복 같은 재료로 몇 번 식을 바꿔 봤다. 결국 심박 선의 1차 변환이라 두 선이 같은 모양이었다. “더 이상 수학 계산으로 만든 값은 못 믿겠다”가 솔직한 결론이었다.
땀(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)로는 꺼지지 않는다.
폰 앱의 하루 보기. 워치와 같은 그리기 코드를 옮겨 와 모양이 같고, 아래에 항목별 최고·최저와 시각이 나온다. 월 보기는 날마다 한 점, 년 보기는 달마다 한 점이다.
삼성 런타임에서 배운 것
문서에 없어서 로그와 실험으로 알아낸 것들이다. 다음에 고칠 때 꼭 알아야 한다.
슬롯 순서가 설정 키다. 꾸미기에서 고른 제공자를 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()
}
Gradle 9.6.0, AGP 9.4.1, Kotlin 2.2.10, JDK는 Android Studio JBR
SDK
Samsung 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에 있다.