
CMF Watch Pro 2용 역설계된 BLE 프로토콜로, GATT 레이아웃, AES-128-CBC 암호화 명령 프레임, 인증 핸드셰이크 및 대체 컴패니언 앱 개발을 위한 건강 데이터 동기화를 문서화합니다.
비공식. 이 문서는 CMF Watch Pro 2(CMF by Nothing)의 Bluetooth Low Energy (BLE) 프로토콜을 리버스 엔지니어링을 통해 재구성한 것으로, 대체 동반 앱을 위한 것입니다. Nothing/CMF와 제휴하거나 보증하지 않습니다. 사용에 따른 책임은 본인에게 있습니다.
프레임 헤더 및 명령어 코드는 모두 빅엔디안(big-endian) 입니다. 명령어 페이로드 내부의 정수는 별도로 명시되지 않는 한 리틀엔디안(little-endian)입니다(이것은 기기 펌웨어를 반영합니다). 예외 사항(GOALS_SET, GPS_PUSH, 벌크 전송 오프셋/길이는 빅엔디안)에 주의하세요.
아래의 명백하지 않은 모든 주장에는 확립된 방법이 태그되어 있습니다.
모든 캡처에 대한 테스트 기기: CMF Watch Pro 2-5485, 펌웨어 1.0.0.73, 일련번호 CI04102520008192, MCU Actions ATS3089C(Cortex-M4), 화면 466×360.
휴대폰은 GATT 클라이언트이고, 시계는 주변 기기로 CMF Watch Pro 2-XXXX(4자리 16진수)로 광고합니다.
각 CCCD(00002902-…)에 01 00을 기록하여 알림을 활성화합니다. 명령 채널(fff1/fff2)은 아래의 프레임 프로토콜을 전달합니다. 쉘 채널(77d4…)은 일반 AT 스타일 텍스트(예: AT GETSECRET; §14 참조)를 전달합니다. 데이터 채널(02f0…)은 명령 채널의 제어 명령어로 조정되는 대용량 바이너리 블롭(시계 화면, 펌웨어, AGPS)을 전달합니다.
✅ 전체 실제 세션이 단일 명령 채널에서 실행되었습니다. 160초의 많은 사용 캡처 동안 명시적인 OTA/시계 화면 전송 중을 제외하고는 데이터/펌웨어 또는 쉘 채널에서 트래픽이 없었습니다.
0xF5)모든 명령 채널 메시지는 하나 이상의 11바이트 헤더 프레임으로 래핑됩니다:``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 0xF5 | chunkLen | cmd1 | chunkCount | chunkIndex | cmd2 | chunk bytes … | | 1 B | 2 B (BE) | 2 B BE | 2 B BE | 2 B BE | 2 B BE | chunkLen bytes | +------+-----------+--------+-------------+-------------+--------+-------------------+ __________________________ 11-byte header ____________________________/
- `cmd1`/`cmd2`가 함께 **opcode**를 형성합니다 ( §6 참조). 🔎 공식 앱의 프레임 빌더(`C6117b.m30831g`)에서 확인됨.
- `chunkCount` = 이 명령의 총 청크 수; `chunkIndex`는 **1부터 시작**합니다.
- `chunkLen` = 이 프레임에 있는 `chunk`의 바이트 수.
- 단일 BLE 쓰기는 링크 MTU에 의해 조각화될 수 있습니다. 수신기는 원시 바이트를 버퍼링하고 완전한 프레임을 다시 추출합니다. 큰 페이로드는 여러 청크(동일한 `cmd1/cmd2`, 증가하는 `chunkIndex`)로 분할되어 순서대로 재조립됩니다.
### Opcode 규칙 (✅ 유선에서 확인됨)
- `cmd1 = 0xFFFF`: `cmd2`가 `0x80xx`/`0x90xx` 범위 = 휴대폰→시계 (요청/설정); `0x00xx`/`0xa0xx` = 시계→휴대폰 (응답). 쌍은 하위 바이트로 매칭됩니다 (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- 기능별 `cmd1`: `cmd2` 접미사 = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.
### 청크 본문
각 청크에 대해 본문은 `payloadPiece ‖ CRC32_LE(payloadPiece)` (4바이트 CRC, 리틀 엔디언, zlib/IEEE)입니다. 명령이 **암호화**된 경우 ( §3 참조), 전체 `payloadPiece ‖ CRC`가 AES-128-CBC/PKCS7 암호화되고 그 암호문이 프레임 `chunk`가 됩니다.
**평문 특이사항:** 평문 opcode의 경우 시계는 `chunkLen`에 4바이트 CRC를 *포함*하지만 **전송하지는 않습니다**. 따라서 평문 프레임을 디코딩할 때 실제 데이터 길이는 `chunkLen − 4`입니다. (암호화된 프레임은 일반적으로 암호문 내부에 CRC를 포함합니다.)
청크 크기 조정 (암호화된 청크가 AES 블록 경계에 맞게), `maxWrite = mtu − 3`:
- 암호화: `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- 평문: `maxWrite − 11 − 4 − 1`
✅ 관찰된 모든 암호화된 프레임의 `chunkLen` 값은 16의 배수였습니다 (블록 정렬 유지).
---
## 3. 암호화 기본 요소
- **AES-128-CBC** ( **PKCS7** 패딩 및 **고정 IV** 사용, 펌웨어 `CmfCharacteristic.AES_IV`에서):
`50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`.
- **CRC32** (zlib/IEEE), 4바이트 리틀 엔디언으로 출력.
- **SHA-256** (부분들을 연결한 값에 대한 해시).
키 파생:```
authkey = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16] // persisted across sessions
sessionKey = SHA256( nonce ‖ authkey )[0..16] // per connection
secret = 16바이트 기기 비밀키 (시계에서 셸 명령어로 얻을 수 있음
AT GETSECRET → GETSECRET:<32-hex>,OK).rnd1 = 전화기가 선택한 16바이트 난수; rnd2 = 시계로부터의 16바이트 난수.nonce = 시계의 논스 응답에서 얻은 바이트.키가 설정된 후, 명령 채널 프레임은 모두 AES 암호화되며, 평문 opcode는 예외입니다. (§5에 나열된 평문 opcode는 제외).
✅ 두 도출 모두 검증됨: 루팅된 전화기의 ntwatch.db에서 복구된 authkey가
캡처된 rnd1/rnd2/secret에서 도출된 값과 일치함; 캡처된 논스에서 재생성된 sessionKey가
실시간 프레임을 복호화함.
두 진입 경로는 동일한 논스/확인 꼬리(tail)를 공유합니다.
phone → (shell) AT GETSECRET watch → (shell) GETSECRET:<32hex>,OK phone: rnd1 = random16 ; signed1 = SHA256(rnd1 ‖ secret) phone → AUTH_PAIR_REQUEST (plaintext) payload = rnd1(16) ‖ signed1(32) // 48 B watch → AUTH_PAIR_REPLY (plaintext) payload = rnd2(16) ‖ signed2(32) // 48 B phone verifies signed2 == SHA256(rnd2 ‖ secret) phone: authkey = SHA256(rnd1 ‖ rnd2 ‖ secret)[0..16] → set crypto key = authkey phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model(UTF-8) // e.g. "CMF Watch Pro 2" watch → AUTH_WATCH_MAC (encrypted) phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5 watch → AUTH_NONCE_REPLY (encrypted) payload = nonce phone: sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5 watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → state = Initialized
On `AUTH_FAILED (0xFFFF,0xA061)` 또는 서명 불일치 시 인증에 실패합니다.
### 4.2 재연결 (authkey already known)```
set crypto key = authkey (persisted)
phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model
watch → AUTH_WATCH_MAC (encrypted)
phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5
watch → AUTH_NONCE_REPLY (encrypted) payload = nonce
sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey
phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5
watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → Initialized
✅ 재연결 순서(셸 트래픽 없음)는 실제 캡처에서 그대로 관찰되었습니다.
⚠️→✅ 데이터 쿼리 전에
TIME이 필수입니다.Initialized이후, 시계는TIME (FFFF 8004)가 세션에서 전송될 때까지BATTERY,SERIAL_NUMBER_GET또는ACTIVITY_FETCH_*핸드셰이크에 응답하지 않습니다. 그렇지 않으면 요청되지 않은FIRMWARE_VERSION_RET만 도착하고 다른 모든 것은 타임아웃됩니다. ✅ 라이브(Pixel 8a)로 확인됨:TIME없이 세 개의 GET 전송 → 펌웨어만 응답;TIME을 먼저 전송 → 배터리 와 시리얼이 응답하기 시작합니다.
권장되는 2단계 순서: TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET →
BATTERY (0xA5) → 구성 푸시 → 건강 동기화(§8).
대부분의 설정에 대해 별도의 "읽기" 명령어는 존재하지 않습니다. *_GET(cmd2 = 0x0002, 페이로드
0xA5)을 보내면 시계는 현재 값을 포함하여 SET 명령어(cmd2 = 0x0001)로 응답합니다.
SET 명령어는 cmd2 = 0x0003과 빈 본문으로 승인됩니다.
키가 설정되면 프레임은 AES로 암호화됩니다. 단, 다음 명령어는 항상 평문입니다:
AUTH_PAIR_REQUEST (FFFF 8047), AUTH_PAIR_REPLY (FFFF 0048)DATA_CHUNK_WRITE_WATCHFACE (FFFF 9064), DATA_CHUNK_WRITE_FIRMWARE (FFFF 9042),
DATA_CHUNK_WRITE_AGPS (FFFF 905F)프레임 헤더(cmd1/cmd2)는 항상 평문으로 전송되므로, 키가 없어도 명령 시퀀스는 캡처에서
볼 수 있습니다. 암호화된 페이로드만 sessionKey가 필요합니다.
(cmd1, cmd2)GET/SET/REQUEST = 폰→시계; RET/REPLY/ACK/RESPONSE/DATA = 시계→폰.
| 이름 | cmd1,cmd2 |
|---|---|
| MUSIC_INFO_SET / _ACK | FFFF 905C / FFFF A05C |
| MUSIC_BUTTON | FFFF A05D |
| 이름 | cmd1,cmd2 |
|---|---|
| WEATHER_SET_1 (작동하는 것) | FFFF 906B |
| WEATHER_SET_2 (Pro 2에서 무시됨 — §9 참조) | 0066 0001 |
JS 전용 명령어(
FFFF 8051,FFFF 0051,FFFF 90A2,FFFF 90C5,FFFF A056,FFFF 908A/908BChatGPT 상태/지원)는 Java 레이어가 아닌 앱의 Hermes 바이트코드에서 처리됩니다. 해당 헤더는 캡처에 나타나지만 페이로드 의미는 ⚠️ [불확실] 합니다.
워치페이스/펌웨어/AGPS는 init → 청크 요청/청크 쓰기 루프 → 완료 승인을 사용합니다:
(모든 cmd1 = FFFF.) 시계는 DATA_CHUNK_REQUEST_*(offset, length)를 내보내 루프를 구동합니다.
(offset/length = u32 빅 엔디언); 폰은 데이터 특성에 payload[offset..offset+length]를
포함하는 DATA_CHUNK_WRITE_*로 응답합니다. 자세한 내용은 §11–§12를 참조하십시오.
TIME (FFFF 8004) 페이로드 = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). 인증 직후
전송되어 시계가 현지 시간을 표시하도록 합니다(데이터 쿼리 차단 해제 — §4.3 참조).
⚠️ 시계의 건강 타임스탬프는 UTC입니다. 동반 앱은 현지 UTC 오프셋을 추가하여 현지 달력 일/시간을 도출해야 합니다. (원시 UTC 일로 건강을 버킷팅하면 잘못된 현지 시간에 일이 넘어갑니다.)
TIME_FORMAT (005F 0001) 페이로드 = 1바이트: 00 = 24시간, 01 = 12시간.
ACTIVITY_FETCH_1을 전송; 시계가 ACTIVITY_FETCH_ACK_1로 응답(첫 번째 바이트 01 ⇒ 준비 완료).ACTIVITY_FETCH_2를 전송; 그러면 시계가 데이터 프레임 버스트를 푸시합니다:
ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].동기화는 순차적입니다(TIME을 따라야 하며, 시계는 ACK_2 후에 스트림을 해제합니다).
단일 버스트가 아닙니다. 무거운 세션은 약 160초에 ~170–210개의 알림 프레임을 푸시합니다. ✅
ACTIVITY_DATA (각 32바이트, LE) ✅칼로리 단위: 활동 칼로리는 cal(그램-칼로리)로 보고됩니다. 일일 합계를 1000으로 나누어 kcal을 얻습니다. (운동 요약 칼로리는 대조적으로 이미 kcal입니다.)
timestamp(i32 LE) ‖ value(i32 LE)
(값 = bpm / SpO₂ % / 스트레스 지수).00DA 0001)는 다릅니다 — 5바이트: timestamp(i32 LE) ‖ hr(u8).
✅ 라이브 예 5e dc 29 6a 4e → ts, hr = 78 bpm. 스트레스 점수 범위: 1–29 / 30–59 / 60–79 / 80–99.SLEEP_DATA (18바이트 헤더 + N × 8바이트 레코드) ✅하나의 SLEEP_DATA = 한 수면 세션; 밤에는 여러 개가 포함될 수 있습니다(미세 각성이 세션을 분할).
헤더:
각 8바이트 레코드: timestamp(u32) ‖ duration_s(u16) ‖ stage(u16).
단계 코드: 1 = 깊은 수면, 2 = 핵심/얕은 수면, 3 = REM, 4 = 각성. ✅ 전체 밤(두 세션,
D/C/R/A 합계 조정)에 대해 검증됨.
WORKOUT_SUMMARY v1 (54바이트) / _V3 (0160 0001)v1: start(u32), end(u32), duration_s(u32), 그 다음 유형/칼로리/걸음수/거리/평균 심박수 및
GPS/확장 블록. ✅ v1 레이아웃이 펌웨어에 대해 확인됨. WORKOUT_SUMMARY_V3는 동일한 데이터에
대한 최신 레이아웃이며, 약 40바이트 확장 블록(exerciseLoad, 유산소/무산소, 회복 시간,
VO₂max, 케이던스, PAI, 최고 러닝 시간…)을 추가합니다. 필드 세트는 알려져 있지만(앱의
Room DB에서) 해당 40바이트 블록 내의 정확한 바이트 오프셋은 ⚠️ [불확실] 합니다.
이를 확정하려면 GPS 운동의 원시 캡처 한 번이 필요합니다.
문자열은 UTF-8이며, 필드 크기로 바이트 단위 잘림(잘림은 멀티바이트 문자를 분할할 수 있으며,
이는 펌웨어의 s.encode()[:max] 동작과 일치함); 짧은 필드는 오른쪽에 0으로 패딩됩니다.
0065 0001) ✅: iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ title ‖ body.
iconCode는 앱 아이콘을 선택합니다(WhatsApp=8, Telegram=12, Instagram=18, Gmail=27; 알 수 없음=0xFF).
제목 ≤ 20바이트, 본문 ≤ 128바이트. 클라이언트에서 전송 → 시계가 표시 + ACK 0065 0003.005C 0001) ✅: 응답 = level(1) ‖ charging(1) (예: 3b 00 = 59 %, 충전 중 아님).00DE 0001) ✅: len(1) ‖ ASCII (예: 10 + "CI04102520008192").0095 0001) ✅: height_cm(1) ‖ weight_kg(1) ‖ age(1) ‖ gender(1: 1=남성)
(예: = 172 cm / 73 kg / 31 / 남성).now/utc_offset을 명시적 매개변수로 사용합니다
(결정적이고 테스트 가능). 전송 계층이 실제 시간을 제공합니다.TIME이 모든 것을 차단합니다(§4.3) — 먼저 전송하지 않으면 시계가 데이터 쿼리에 응답하지 않습니다.GOALS_SET 및
GPS_PUSH는 빅 엔디언이며, 대량 전송 오프셋/길이는 빅 엔디언입니다.시계는 (a) 사진/사용자 지정 다이얼(배경 이미지 + 펌웨어가 그리는 디지털 시계)과 (b) 구조화된 다이얼(내장/스토어 페이스: 배경과 위치 지정된 스프라이트 레이어, 시침, 텍스트 위젯)을 지원합니다. 둘 다 §6의 init → 청크 루프를 통해 데이터 채널을 통해 전송됩니다.
실제로 작동하는 것(✅ 라이브 검증됨): 모든 이미지에서 사진 다이얼을 제작하여 설치; 103개의 스토어 다이얼을 오프라인으로 설치; 구조화된 다이얼 리스킨(배경 또는 비배경 스프라이트 교체) 및 레이어 이동; 활성 페이스 재정렬/전환; 그리고 처음부터 구조화된 다이얼 제작 —
0x20장면 봉투는 디코딩되었으며 빌더가 구현됨(§11.7), 모든 103개 스토어 다이얼을 바이트 단위로 왕복하고 펌웨어 자체 유효성 검사기를 통과하는 합성 컨테이너를 내보내는 것이 오프라인으로 입증됨. 🟡 유일하게 입증되지 않은 단계는9075를 통해 장치에서 처음부터 합성 렌더링을 관찰하는 것입니다(구조적 오프라인 증명이 이미0a거부의 원인을 다루고 있음). 코덱 또는 전송 장벽이 없으며 공급업체 도구 체인이 필요하지 않습니다. 이전의 "구조화된 렌더링은 RES-pack에 고정되어 있음 / BLE로 불가능" 및 "cf=0x1f 서버 측 코덱" 주장은 잘못되었습니다(오프셋+바이트-픽셀 버그) — 펌웨어는 보내는 파일에서 데이터 기반으로 구조화된 다이얼을 렌더링합니다.
DIAL_COMMAND (9055 / a055) ✅a055 = result(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. 예: 01 05 06 07 … = 활성 #5, 다이얼 6개, 최대 7개.CHANGE_DIAL (009F 0001)는 fw 1.0.0.73에서 비활성(상수를 반환하며 전환하지 않음) — 사용하지 마십시오.INIT1 8052 (payload A5) → 0052 [0]=01 INIT2 9063 (photo, APPEND) | 9075 (structured, REPLACE) → A063 / A075 [0]=01 [ watch → DATA_CHUNK_REQUEST A064 (offset, length; u32 BE, +progress u8) phone → DATA_CHUNK_WRITE 9064 (bytes[offset..offset+length], plaintext) ] × N FINISH A065 → 9065 (payload A5)
완료 응답 바이트: `01` = 활성화 및 저장됨; `0a` = 저장되었지만 **활성화되지 않음** / 거부됨. Android에서는 각 `DATA_CHUNK_WRITE`가 **프레임당 하나의 BLE 쓰기**로 전송되어야 합니다 — 연결하고 MTU로 다시 슬라이싱하면 헤더가 동기화되지 않고 시계가 오프셋 0을 요청하는 루프에 빠집니다.
- **`9063` (사진) = APPEND.** 다이얼 목록이 증가합니다(6→7). `watchfaceId = 0xFFFFFFFF` (사용자 정의 sentinel)이므로 중복으로 거부되지 않으며 시계가 자동으로 활성화합니다.
- **`9075` (구조화됨) = REPLACE** `old_id` 슬롯을 대체합니다. `old_id`는 이미 목록에 **있어야** 합니다 (그렇지 않으면 `0a`). 이미 존재하는 ID를 재설치하려면 **먼저 삭제**한 다음 (9055 목록-마이너스-ID) "새로" 업로드하십시오 — 이미 있는 ID를 재사용하면 `0a`가 발생합니다.
### 11.3 사진 / 사용자 정의 다이얼 — ✅ 종단 간 완전히 검증됨
**컨테이너** (바이트 검증된 왕복; 모든 필드 리틀 엔디언):```
0x00 magic 6c 8d c4 a5
0x04 count 12 00 00 00 (=18) [constant, NOT an element count]
0x08 00 × 8
0x10 lenFull u32 LE (length of the whole FULL block: tag+len+payload)
0x14 FULL tag 04 48 47 3a ‖ payloadLen(u32 LE) ‖ LZ4(RGB565-LE) → 466×466 [raw 434312 B]
THUMB tag 04 38 c4 21 ‖ payloadLen(u32 LE) ‖ LZ4(RGB565-LE) → 270×270 [raw 145800 B]
EOF-4 magic 6c 8d c4 a5 [trailer = magic repeated]
Codec = RGB565 리틀 엔디언, 탑다운 방식의 표준 LZ4 블록 (payloadLen은 첫 번째 LZ4 바이트부터 카운트). 공식 앱은 LZ4-HC를 사용하며 21바이트 LZ4 블록 헤더/푸터를 제거합니다. 일반 리터럴 전용 LZ4 인코더도 작동합니다. 워치는 유효한 모든 LZ4를 허용하며, 바이트 동일성은 요구되지 않습니다. 내접 원(중심 233,233, 반지름 233) 외부의 픽셀은 0x0000으로 설정됩니다.
9063에 대한 INIT_2 — 정확한 헤더 (✅ 작동하는 헤더입니다):```
01 ‖ size(u32 BE) ‖ FF FF FF FF ‖ 01 01 01 ‖ styleId(u16 BE) ‖ posX(u16 BE) ‖ posY(u16 BE) ‖
color565(u16 BE) ‖ FF × 8
`size` = 정확한 `.bin` 길이; `FFFFFFFF` = 사용자 정의 `watchfaceId`; `styleId` 0–4는 내장 디지털 시계 레이아웃을 선택합니다 (항상 그려집니다 — '끄기' 없음); `posX/posY`로 위치를 지정합니다 (알려진 양호값 56 / 77); `color565`로 색조를 적용합니다 (예: `FFFF` = 흰색). ⚠️ `A5 ‖ size ‖ watchfaceId` 형식의 짧은 버전은 finish `0a`로 **거부**됩니다 — 위의 전체 헤더를 사용하세요. (참조 구현: `core-rust/engine.rs::build_wf_init2`, 공식 앱의 `C6135t.m31104u`를 미러링함)
**레시피:** 이미지를 466×466(및 270×270 썸네일)로 크기 조정, RGB565-LE 상단-하단으로 변환, 선택적으로 원 외부 픽셀을 0으로 설정, 각각 LZ4 압축, 위 컨테이너를 조립한 후 `watchfaceId = 0xFFFFFFFF`로 `9063` 파이프라인을 통해 업로드합니다. (참조 코덱: `core-rust/watchface.rs`, `work/codec_dfa.py`)
### 11.4 구조화된 / 스토어 다이얼 — 컨테이너 및 코덱 ✅
**헤더** (모든 103개 스토어 다이얼에서 동일; 모든 필드는 리틀 엔디언):```
0x00 perDialId u32 LE [per-dial id/hash; NOT a content checksum — 4 "Default" dials share one]
0x04 version 0x00000001 [constant]
0x08 name char[] [NUL-terminated, e.g. "SlopeTime", "Metaball"]
0x18 size_a u32 LE [= filesize − 36] ✅ 100 % confirmed across 103 dials
0x1c size_b u32 LE [data-section length, < size_a]
0x20 3× u32 LE id/hash words [not a CRC]
0x2c name (repeated on larger dials)
~0x60 directory of layer records (61 xx 00 …) then the asset pool
차단 체크섬이 없습니다 (CRC32/Adler32/바이트 합계 모두 일치하지 않음) — 재패킹이 제한되지 않습니다. 스텁 다이얼(~173 B, 예: ID 273/274/277)은 ROM에 내장된 페이스의 자리 표시자입니다: 헤더 + 디렉토리, 실제 에셋 없음.
에셋 — 각각 dimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload) 형식이며,
여기서 cf = dimsWord & 0x1f, w = (dimsWord >> 10) & 0x7FF, h = (dimsWord >> 21) & 0x7FF, len은
첫 번째 LZ4 바이트부터 셉니다 (자주 보이는 1f 00 01 00은 첫 번째 LZ4 토큰입니다 — 건너뛰지
마세요). 압축 해제 크기 = w·h·bpp:
✅ 103개 다이얼의 4151/4151개 에셋 모두 w·h·bpp에서 표준 lz4.block
압축 해제기로 정확히 디코딩됩니다. 투명도는 알파 바이트(cf=5/24) 또는 0x0000(원 외부 cf=4)입니다
— RLE나 "이스케이프"는 없습니다. 인코딩 = 재래스터화 → 표준 LZ4 → [dimsWord][len][LZ4].
9075용 INIT_2 — AES 암호화 본문:```
kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)
`kind` = `0x02`/`0x03`; `old_id` = 현재 활성 다이얼 (`9055`에서); `file_len` = 실제 `.bin` 크기 (= `@0x18 + 36`). 스토어 `.bin`을 그대로 설치하는 것이 보장된 경로입니다 (Ring Data id 359 + 102개 확인됨). (참조: `core-rust/engine.rs::build_dial_replace_init`.)
### 11.5 구조화된 디렉터리 문법 ✅ (디코딩 및 구현 완료 — 수정일 2026-07-02)
> **⚠️ 수정 (2026-07-02): 아래의 플랫 `61 01 00` 레코드 스키마는 체계적으로 1 오프였습니다.**
> 장면 본문은 깨끗한 TLV입니다 (§11.7). 그리기 가능한 **리프 본문**(태그 `0x30`/`0x38` 정적, `0x70` 포인터)은 다음과 같습니다:
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - 속성 `0x01`이 본문을 엽니다: **X,Y = 466² 캔버스의 왼쪽 상단** (SDK의 `sty_picture_t`의 `s16 x,y`).
> - **프레임 테이블 `61 …`은 본문을 닫습니다** (`base` = 자산 포인터; `count` 1 = 이미지, 10/11 = 숫자 아틀라스 — 이전의 "레코드 타입 `0a/0b`"는 실제로 이 카운트였습니다! — 7/13/2 = 컴플리케이션 프레임 시트).
> - 포인터 추가: `0x01` 속성 내 `[src] 00 3c 00` 소스+스케일; `05 05 00 01 [pivX][pivY]` **트레일러**의 피벗. **회전 중심 = 포인터당 `(X+pivX, Y+pivY)`** — 고정 (233,233)이 아닙니다: 오프센터 서브다이얼이 존재합니다 (예: 다이얼 366의 바늘은 150,150 주위로 회전).
>
> `61 01 00`에 대한 선형 스캔은 요소 **N**의 프레임 테이블+피벗을 요소 **N+1**의 X/Y (및 태그 바이트, 이전의 "f3")에 결합하고 있었습니다 — 인접한 바늘이 거의 동일한 형상을 공유하는 아날로그 다이얼에서만 *올바르게 보였습니다*. "컴팩트 변형 벽"(명세 24 §24.4.5)도 이와 같은 오독이었습니다. `core-rust/watchface_struct.rs` 및 `wfweb/src/codec/parse.ts`에서 `scan_scene_drawables`로 구현됨 (장면 = 이미지/포인터의 기본 소스; 텍스트 및 비-인벨로프 폴백을 위해 플랫 스캔 유지). `wfweb/compare.html` 오라클(렌더링 vs 공식 스토어 PNG, 99개 다이얼)에 의해 검증됨: 64→72 좋음, 8→5 나쁨, 평균 차이 9.3→7.4%.
히스토릭 플랫 레코드 읽기 (대체됨, 컨텍스트용으로 유지):
- **정적 이미지** (`61 01 00`): `asset_ptr(u32) ‖ elemId(u16) ‖ 05 05 00 01 ‖ pivotX(u16) ‖ pivotY(u16) ‖ 3B ‖ 01 ‖ 1b 00 ‖ X(u16) ‖ Y(u16)`. 466² 캔버스에서 왼쪽 상단 = `(X−pivotX, Y−pivotY)`.
- **포인터/핸드** — 동일한 이미지 레코드, 런타임에 회전. **회전 중심 = `(X+pivotX, Y+pivotY)`** (아날로그 다이얼에서 ≈ 233,233). **데이터 소스는 레코드 오프셋 `+36`의 `u8`, `+38`의 스케일 `u16` (=60)**: `0x0a`/`0x70` = 시 (`h·30°+m·0.5°`), `0x0e`/`0x71` = 분 (`m·6°+s·0.1°`), `0x12`/`0x72` = 초 (`s·6°`). ✅ 게터를 디스어셈블하여 확인됨 (RTC 폴백 10:10:30).
- **텍스트/숫자 위젯** (`61 0a 00`): `asset_ptr(u32) ‖ [10×u16 font metrics] ‖ 40 01 00 ‖ flag ‖ 3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`. `asset_ptr`은 글리프 "0"을 가리킴; 숫자 *d* = `index("0") + d`에 있는 자산 (연속된 10개의 cf=5 스프라이트, 예: `0123456789` 및 `,°` 구두점). ✅ 렌더링됨.
- **컴플리케이션 채움 = 프레임 인덱스** (✅ 숫자/열거/게이지 컴플리케이션, count>1에 대해 확인됨): 값은 `.bin`의 **미리 렌더링된 프레임 시트**를 인덱싱함 — `frame = (count−1)·val/100` (백분율) 또는 `frame = value` (플립 숫자/열거). 프레임 테이블 = 서브-레코드 `61 ‖ count(u16) ‖ base(u32) ‖ count×id(u16)`. 예: 327 Digit Max의 큰 시침은 13프레임 시트 (숫자 0–12), `frame = hour`입니다.
- **진행 링/호 = 런타임 섹터 클립** (✅ 2026-07-02, **명세 25 §2의 "링은 프레임 시트" 읽기 수정**): 요소 태그 **`0x81`**은 **단일** 전체 디스크(`61` 프레임-테이블 `count == 1`)를 전달하며, 부분적인 웨지는 해당 디스크가 **파이 섹터로 클리핑**된 것입니다 (`frac = value/max`, 12시 방향부터 시계 방향) — 322 Glare 2에서 픽셀 단위로 검증되었으며 **20개 다이얼**에서 `count==1`이 확인됨. 디스크 상: `0x81` 본문 = 하위 `0x01` (형상 `x@+0 y@+2 w@+4 h@+6`, 인라인 `61 1 base` = 디스크) + 하위 `0x5b` (`max` u16 `@+4`, =100, 332=60 제외). wfweb에서 구현됨 (`blendSector`).
- **데이터 소스 id** — 요소의 `82` 속성-블록은 `delim+3` (마지막 `40 01 00` 이후)에 위치하며, **소스 id는 `+0x14`의 `u8`** (또한 `relX@+0x07 s16`, `relY@+0x09 s16`, `anchor@+0x0C/0E`, `mode@+0x15`, `frame-count@+0x1A`). 앵커 < 0 = 부모의 가장자리에 정렬. 🔎 펌웨어는 `0x101f371c`의 142-엔트리 게터 테이블을 통해 id를 해석합니다 (각각 `ux2sys_get(type)` 호출). 일반적인 id (§16): `0x07` 시, `0x0b` 분, `0x0f` 초, `0x16` 월, `0x18` 요일, `0x13` AM/PM, `0x19` HR, `0x1b` 배터리 %, `0x24` 온도, `0x36` 걸음 수, `0x70/71/72` 핸드 각도, `0x25–27` 목표 %. (아래 HH:MM:SS 그룹 예제의 `0x07:0x0b:0x0f`와 일치합니다.)
- **그룹 노드** (`0x68`): 자식 요소를 자체 TLV 본문(`0x60` = 값/텍스트, `0x30` = 정적) 내에 중첩시킵니다. 각 `0x60`은 `data+16`에 소스 id를 전달합니다. 예: 그룹 `0x07:0x0b:0x0f` = HH:MM:SS 시계. TLV 요소 파서 = `0x100db55c` (`tag−0x70`으로 인덱싱된 점프 테이블).
### 11.6 제작 매트릭스
| 경로 | 상태 | 참고 |
|---|---|---|
| 모든 이미지에서 포토 다이얼 | ✅ **완료** | §11.3; 기기에서 검증됨 |
| 103개 스토어 다이얼 중 임의 설치 | ✅ **완료** | §11.4; `9075`, `old_id`=활성 |
| 스토어 다이얼의 cf=4 배경 리스킨 | ✅ **실시간 작동** | FULL 페이로드를 제자리에서 교체, 자산 `len`을 **새** 블록 크기(≤ 이전)로 설정, 동일한 파일 풋프린트 유지, 새로 설치 |
| 템플릿으로 재작성 (레이어 픽셀 교체 + 형상 이동) | ✅ **BLE로 렌더링** | 다이얼 373: 배경→청록 + cf=5 스프라이트→빨간색 + X 224→100 이동, 모두 렌더링, 바늘 실시간 |
| 처음부터 100% 합성 구조화 다이얼 | ✅ **빌더 완료, 오프라인 검증됨** | `watchface_struct.rs`의 `0x20` 인벨로프 빌더 (`build_container`/`serialize`/`validate_container`); 103개 다이얼 모두 바이트 정확히 왕복 + 합성 펌웨어 검증기 통과 (§11.7). 🟡 `9075`를 통한 기기 렌더링은 아직 촬영되지 않음 |
| 시스템 폰트 (`.font`) | ✅ **디코딩/렌더링 (전체)** | LVGL bin (독점 아님); 32개 숫자 폰트 (`num*/nm*`, 압축되지 않음) + 24개 텍스트 폰트 (`font*`, LVGL RLE `comp=1`) 모두 디코딩 — 12208 글리프, 0 오버런, 전체 ASCII. RLE = LVGL v8.3 `lv_font_fmt_txt.c` (3-상태 SINGLE/REPEATE/COUNTER + 행별 XOR 프리필터), 1:1 포팅, 디스어셈블리 없음 |
⚠️ 리스킨/재작성 시 검은 화면 또는 `0a`를 유발하는 함정: **이전 자산 `len`**을 그대로 두는 경우(워치가 블록을 지나 읽음 → 오버런 → 검은 화면); **파일 크기 증가** (설치 시 거부됨); id를 새로 설치하지 않고 **제자리에서** 재사용하는 경우.
### 11.7 `0x20` 장면 인벨로프 — 디코딩 및 빌더 구현 완료 ✅
재작성된 **실제** 다이얼은 파일의 장면 인벨로프를 유지하기 때문에 렌더링됩니다. 순수 합성된 플랫 `61 …` 레코드 본문은 **거부**됩니다 — 펌웨어 파서(`WFManager_Parser`, `0xdb35c`)는 본문(오프셋 `0x24`부터)이 `0x20` 장면 컨테이너로 시작해야 합니다. 전체 파일은:```
[0x00,0x24) header: perDialId@0 · version=1@4 · name[16]@8 · size_a@0x18 · size_b@0x1c · idWord0@0x20
[0x24, fa) scene: 20 <u16 L0> ( 21 <u16 L1> ( 86 <len>=name , 30/70/80/81… drawables ) [ 22 … AOD ] )
[fa, EOF) assets: [dimsWord u32][len u32][payload = 1f 00 01 00 + LZ4] …
size_a = filesize−36 · size_b = filesize−36−first_asset · 0x27+L0 == first_asset
장면은 깔끔한 중첩 TLV입니다 — [tag u8][len u16 LE][body], 컨테이너 태그 0x20/0x21/0x22/0x68 재귀, 리프 드로어블 0x30(정적) / 0x70(요소/포인터) / 0x80 / 0x81 / 0x86(이름).
(평탄한 61 01 00 / 61 0a 00 레코드는 드로어블 본체 내부에 있는 패턴입니다. 이전 파서는 휴리스틱하게 찾았고 — 인접한 본체를 서로 연결했습니다. §11.5 개정 참조. 이제 드로어블 본체 레이아웃이 완전히 디코딩되었습니다.) 모든 자식의 offset+len은 부모의 윈도우 내에 맞아야 합니다.
첫 본체 바이트가 0x20이 아님 → 파서 오류 -16; 자식이 윈도우를 초과 → -2; 둘 중 하나가 9065 핸들러(0xeb50c)가 finish 0a를 쓰도록 만듭니다.
빌더가 구현되었고 오프라인 검증되었습니다 (core-rust/watchface_struct.rs:
SceneNode / serialize / parse_scene / validate_container / build_container /
build_container_raw; CLI cmfwatch-wfgen reframe):
scene_roundtrip_identity — 모든 103개 스토어 다이얼: parse_scene→serialize가 장면을 바이트 단위로 재현하고(다시 계산된 중첩 len 일치) validate_container가 모든 경우에 통과합니다.build_reframe_identity / CLI reframe — 처음부터 전체 .bin을 재조립하여 1개의 이름 패딩 바이트(@0x17; 체크섬 아님)를 제외하고 파일을 바이트 단위로 재현합니다.build_container_synthetic — 펌웨어의 정확한 불변 조건을 통과하는 새 다이얼(배경 + 20→21에 중첩된 드로어블)을 구성합니다(build_container가 올바른 중첩 윈도우를 방출).validate_rejects_bad_containers — 평탄한 본체(→ , 역사적인 버그)와 윈도우를 초과하는 자식(→ )을 거부합니다.🟡 아직 입증되지 않음 (시계 필요, 비차단): 처음부터 만든 합성 다이얼을 9075를 통해 업로드하고 렌더링을 확인 — 오프라인 구조적 증명이 이미 0a 거부 원인을 다루고 있습니다.
wfweb 렌더를 공식 스토어 썸네일(전체 103개 다이얼에 대한 픽셀 오라클)과 교차 검증하여 네 가지 격차를 해소했습니다:
i16 (부호 있음) ✅ 앵커는 캔버스 밖으로 확장되는 요소에 대해 음수일 수 있습니다 — 예: 275의 빨간 초침이 Y = 0xFFFC = −4에 위치(30×281 스프라이트, 소스 0x12, 중앙에서 위쪽 가장자리 밖으로 회전). X/Y를 u16(65532)으로 읽으면 가드가 이를 버렸습니다. 둘 다 부호 있는 값으로 파싱하고 작은 음수 범위를 허용합니다.0x60 img_numbers일 수 있음 (0x68 그룹 내부뿐만 아니라), 그리고 **실제 데이터 소스는 레코드 오프셋 −5의 u8**입니다 — 순방향 82-속성 스캔이 여기서 체계적으로 하나씩 어긋나서 다음 형제의 속성을 가져옵니다(275에서 분 숫자가 요일 0x18을 가져옴). 275의 "10:10" = 시간 0x07@X≈306 + 분 0x0b@X≈369, 사이에 :가 인접한 정적 요소로 있으며, 각각 11글리프 아틀라스(61 0a 00)입니다. ⚠️ X/Y를 에서 수정할 때 , 그렇지 않으면 재내보내기 시 해당 바이트가 손상됩니다(동일 풋프린트 깨짐 → ).또한: 공식 스토어 썸네일은 10:10 (클래식 마케팅 시간)으로 렌더링되며, 10:12가 아닙니다 — 오라클 시간을 10:10으로 맞추면 평균 픽셀 차이가 눈에 띄게 줄어듭니다. wfweb의 파서는 이제 모든 103개 다이얼을 바이트 정확하게 왕복합니다(위의 X/Y 쓰기 오프셋 수정으로 마지막 불일치가 제거됨).
0x22 AOD 컨테이너를 별도 보기로 분리 ✅ 장면 워커는 이미 0x22를 건너뛰지만, 평탄한 텍스트/숫자 스캔은 전체 [0x30, firstAsset)을 탐색했습니다 — 따라서 각 요소의 항상 켜짐(AOD) 변형을 일반 레이어로 내보냈습니다. "Gradient"에서 AOD 회색 날짜 아틀라스(0x22 내 오프셋)가 빨간색 일반 날짜 위에 그려졌습니다. 수정: 모든 0x22 레코드에 layer.aod=true를 태그하고(자체 중복 제거 세트 포함) renderAt(…, aod)가 AOD 모드에서만 표시하도록 합니다(일반 모드는 aod 레이어를 숨김; AOD 모드는 일반 레이어를 숨김; 배경은 setAod로 교체되며 항상 그려짐). 말뭉치 전체의 일반 모드에서 순수 오라클 승리 (284: 31%→21%, +18개 기타) — AOD 변형이 많은 다이얼에서 오버드로잉하고 있었습니다 — 그리고 편집기의 AOD 토글이 이제 일반 레이어 대신 실제 항상 켜짐 레이아웃을 표시합니다. 실제 AOD는 검은색 화면입니다(어두워진 장면 없음): 다이얼에 전용 AOD 배경 프레임(dial.aod)이 없으면, AOD 모드에서 일반 장면이 숨겨져 검은색 + 0x22 요소가 자체 색상으로 렌더링됩니다. AOD 침도 장면 워커를 통해 파싱됩니다(이제 0x22 컨테이너를 재귀하며 드로어블에 태그를 지정하고, 피벗이 일치하지 않아 "위치 미지정" 상태가 된 평탄한 스캔에 맡기지 않음). AOD 침은 캔버스 중앙에서 회전합니다(는 때때로 펌웨어가 무시하는 중앙이 아닌 침 x/y를 전달함 — 예: Gradient의 시간 ). 편집기는 또한 이를 로 노출합니다(§UI): 각 화면은 자체 레이어만 표시하고 편집은 독립적으로 유지됩니다. 일반 모드 렌더는 전체적으로 바이트 동일합니다; 왕복은 모든 103개 다이얼에서 바이트 정확하게 유지됩니다.40 01 00 XX 바이트 (✅ 펌웨어 확인)img_number가 그리는 자릿수는 필드 레코드의 단일 바이트입니다 — 요소의 40 01 00 XX 속성 서브레코드의 데이터 바이트 XX (61 [count][base][glyph-ids] 프레임 테이블 뒤에 위치한 0x40 서브레코드):
XX & 0x0F = 자릿수 슬롯 개수 (0 ⇒ 펌웨어 기본값 7).0x80 = 제로 패드 (선행 0 표시, 예: "09" vs "9").펌웨어 디스어셈블리(XIP 이미지 0x10000000; 렌더 루틴 0x100d8e60)를 통해 확인됨:
NDIG = ldrb[40sub+3] & 0x0F (0이면 7); 값은 value % 10^NDIG로 클램프되며 정확히 NDIG개의 글리프가 MSB 우선으로 그려지고, 비트7이 아니면 선행 0이 생략됩니다. 소스 뒤의 u16(날짜의 경우 60, kcal의 경우 1000)은 자릿수가 아닙니다 — 이는 천/백만 단위 구분 기호-글리프 삽입(cmp #1000/#1000000)에만 사용되며, 이를 수정해도 아무 효과가 없는 이유입니다. 소스 ID도 제한하지 않습니다.
모든 620개 숫자 필드에 대한 말뭉치 히스토그램 일치: 2자리 필드(시/분/초/날짜/온도/심박수)는 40 01 00 02/0x82로 끝남; kcal …04; 걸음 수 …05; 한 자리 시계 분할 0x81. 따라서 날짜 필드 40 01 00 82 = 2자리, 제로 패드 — 이것이 반등한 화씨 온도(≥100)가 잘린 전체 이유입니다.
수정 / 편집기: wfweb은 숫자 필드에 대해 digitCount/digitZeroPad (+digitCountOff)를 파싱하고, 인스펙터에 "Digits" + "Zero-pad" 를 노출하며, 바이트를 제자리 (동일 풋프린트) 에 쓰고, 미리보기는 digitCount로 클램프/패드하여 펌웨어를 반영합니다. 따라서 필드의 소스를 다시 바인딩하고 자릿수를 설정하면 모든 필드에서 작동합니다(예: 날짜→온도 °F → Digits 3). 일반 모드 오라클 변경 없음(0개 회귀, 3개 사소한 개선); 왕복은 모든 103개 다이얼에서 바이트 정확.
전송 테이블은 §6에 있습니다. 추가 확인된 사항:
000000010000…로 시작합니다. 전체 init → [A05F ↔ 905F]×N → finish 루프가 와이어에서 관찰됨(~892 청크).9040–9042, finish 9041) 🔎: 구조 매핑됨; INIT2 페이로드 = 버전 바이트(예: 0b 00 00 39 = 11.0.0.57). 현장 테스트되지 않음(앱이 여기서 FW 업데이트를 비활성화). 펌웨어 이미지는 서명되지 않은 것으로 보임 — 무결성은 CRC32만 있음 (RE에서 비대칭 서명 관찰되지 않음).FACTORY_RESET (009A 0001)가 인증된 세션을 공유하므로, 단일 유효한 BLE 인증으로 시계를 초기화하거나 (원칙적으로) 벽돌로 만들 수 있습니다. 주의해서 다루십시오.✅ BLE를 통해 노출된 하드웨어:
WORKOUT_GPS) 및 위치 푸시(GPS_PUSH).기압계/고도계, 나침반, 자이로스코프, 피부/체온 센서는 없습니다. 내부 NTC 서미스터(보드/배터리 온도)는 존재하지만 AT 채널을 통해서만 읽을 수 있습니다(AT GETNTCTEMP, §14) — 이 SKU에서 0155 피부 온도 기록 스트림은 비어 있습니다.
데이터 위젯 하이재킹 (실제 컴플리케이션/데이터 바인딩 API 없음 — §11.5 참조): 시계의 기존 텍스트 필드를 재사용하여 한눈에 볼 수 있는 외부 데이터를 표시할 수 있습니다. 입증됨 ✅: 날씨 도시 문자열(WEATHER_SET_1, 예: "BRA 2x1 ARG"이 위젯에 나타남) 및 음악 트랙/아티스트 필드; 연락처 목록(20 × 이름[32]+번호[25])은 스크롤 가능한 데이터 패널로 작동합니다. 모두 푸시 방식이며 지속적인 컴플리케이션이 아닙니다.
77d4ff01 / 77d4ff02)프레임 프로토콜과 독립적인 별도의 평문 AT 명령 채널. ✅ 실시간 테스트 완료:
AT GETSECRET (16바이트 페어링 시크릿), GETVERSION, GETSN, GETNAME, GETPID,
GETBATLV (원시 mV, 예: 3853mv), GETGSENSOR (원시 가속도 g 단위, X=… Y=… Z=…),
GETNTCTEMP (°C, 내부 NTC).AT SETMOTOR=1 (모터 진동), SETHR/SETHRV/SETSPO2=… (센서 테스트 주입),
SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH.응답은 ,OK로 끝납니다. SET* 명령은 일반적으로 실행되지만 BLE를 통해 ,OK를 에코하지 않을 수 있습니다 — 경우에 따라 확인.
일부 기능은 펌웨어에 존재하지만 SKU/지역에 의해 비활성화되어 있으며 휴대폰/BLE에서 접근할 수 없습니다 — 펌웨어 수정이 필요하며, 이는 여기서 다루지 않습니다:
ux2sys 피처 ID 0x9e, 부팅 시 NVRAM/EFUSE/지역에서 시드됨; 이 SKU에서 지원 플래그 908b = 00. 휴대폰, 계정 또는 BLE로 영향 불가능 (실험 + RE로 확인). 앱은 중계기일 뿐; 오디오는 휴대폰 → Nothing 클라우드로 이동.BLE 클라이언트를 구축하는 데 필요하지 않음 — 완전성을 위해 포함. 펌웨어의 다이얼 렌더러는 각 컴플리케이션 슬롯을 숫자 게터 ID(142개 항목 디스패치 테이블)에 바인딩합니다. 선택된 ID:
0x07 시간, 0x0a 결합 시계 각도, 0x0b 분, 0x0f 초, 0x18 요일,
0x19 심박수, 0x1b 배터리 %, 0x24 온도, 0x36 걸음 수,
0x70/0x71/0x72 시/분/초침 각도, 0x25–0x27 목표 %. 링/아크 컴플리케이션은 픽셀 단위 아크가 아닌 사전 렌더링된 프레임 시트(예: 50% = 100개 중 프레임 50)를 인덱싱합니다 — 프레임은 보내는 .bin에 구워져 있으므로(§11.5) 외부 RES 팩이 필요하지 않습니다.
QUICK_CARD (906D) ✅시계의 홈 타일. 휴대폰은 어떤 타일을 어떤 순서로 표시할지만 선택합니다 — 타일은 펌웨어에 의해 렌더링됩니다(콘텐츠 채널 없음). 첫 번째 페이로드 바이트 = 서브 명령: 00 = GET, 01 = SET; 둘 다 0x906D 사용 (0x906C는 나열되지만 사용되지 않음 — 쿼리하면 타임아웃). 응답 = A06D.
🛑 임의의
assemblyId를 보내면 시계의 화면이 지워집니다 (목록을 수락하지만 ID를 일치시킬 수 없어 아무것도 표시되지 않음). GET을 통해 읽어온 ID만 보내십시오; 공식 앱 또는 공장 초기화를 통해 복구하십시오.
GET 응답 ✅: status(1) ‖ 00 ‖ N(1) ‖ N × group, group = tag=01 ‖ K(1) ‖ K×(assemblyId, sportId).
실제 프레임: 01 00 04 01 02 5d00 6100 01 03 1900 2e00 2300 01 03 5c00 0400 5a02 01 03 4800 5100 5300
= 4개 화면 / 11개 카드 (5a02 = Sport 카드, sportId 2).
슬롯: 각 화면에는 4개의 슬롯이 있습니다. 카드 유형이 크기를 결정합니다 — circular/square = 1 슬롯, rectangle = 2 슬롯. 검증은 순수 슬롯 산술(화면당 Σ ≤ 4); 상호 배타적인 카드는 없습니다. sportId는 Sport 카드(87–91)를 제외하고 0입니다. ID 64 및 95는 존재하지 않습니다.
assemblyId 카탈로그 (각 논리 유형 = 6개 스타일 변형의 연속된 범위 _0.._5; 0 = 빈 슬롯):
위의 바이트 레이아웃은 펌웨어 (1.0.0.73), 공식 APK (3.5.7), 실제 장치에 대한 복호화된 실시간 캡처에서 재구성되었습니다. 이 프로젝트의 참조 구현은 core-rust/src/{commands,frame,crypto,health,session}.rs (Rust)와 cmftool/ Python 도구 (pair.py, session.py, wf_codec.py, upload_custom.py, …)에 있습니다.
| 목적 | 서비스 | 특성 | 속성 |
|---|
| 명령 쓰기 | 0000fff0-0000-1000-8000-00805f9b34fb | 0000fff2-… | Write |
| 명령 알림 | 0000fff0-… | 0000fff1-… | Notify |
| 쉘 쓰기 (AT) | — | 77d4ff01-2fe2-2334-0d35-9ccd078f529c | Write |
| 쉘 알림 (AT) | — | 77d4ff02-… | Notify |
| 벌크 데이터 쓰기 | — | 02f00000-0000-0000-0000-00000000ffe1 | Write |
| 벌크 데이터 알림 | — | 02f00000-…ffe2 | Notify |
| 이름 | cmd1,cmd2 |
|---|
| TIME | FFFF 8004 |
| FIRMWARE_VERSION_GET / _RET | FFFF 8006 / FFFF 0006 |
| SERIAL_NUMBER_GET / _RET | 00DE 0002 / 00DE 0001 |
| BATTERY | 005C 0001 |
| TRIGGER_SYNC | 005C 0002 |
| USER_INFO_SET / _RET 🔎✅ | 0095 0001 / 0095 0003 |
| FACTORY_RESET | 009A 0001 |
| DEVICE_REBOOT 🔎 | FFFF 9080 |
| RESOLUTION_GET 🔎 (→ 466×360) | FFFF 907F |
| GPS_PUSH / _RET | FFFF 906A / FFFF A06A |
| UNBIND_SET / _RET | FFFF 907A / FFFF A07A |
| 이름 | cmd1,cmd2 |
|---|
| AUTH_PHONE_NAME | FFFF 8049 |
| AUTH_WATCH_MAC | FFFF 0049 |
| AUTH_PAIR_REQUEST / _REPLY | FFFF 8047 / FFFF 0048 |
| AUTH_NONCE_REQUEST / _REPLY | FFFF 804B / FFFF 004C |
| AUTHENTICATED_CONFIRM_REQUEST / _REPLY | FFFF 804D / FFFF 0004 |
| AUTH_FAILED | FFFF A061 |
| 이름 | cmd1,cmd2 |
|---|
| APP_NOTIFICATION | 0065 0001 |
| INCOMING_CALL ⚠️ | 0064 0001 |
| CALL_REMINDER_REQUEST / _RESPONSE | FFFF 9066 / FFFF A066 |
| FIND_PHONE | 005B 0001 |
| FIND_WATCH | 005D 0001 |
| FIND_WATCH_TOGGLE | FFFF 9069 |
| SMS_MESSAGE_PUSH / _RET | FFFF 906E / FFFF A06E |
| QUICK_REPLY_SET / _RET | FFFF 9073 / FFFF A073 |
| 이름 | cmd1,cmd2 |
|---|
| ALARMS_SET / _GET | 0063 0001 / 0063 0002 |
| CONTACTS_SET / _GET | 00D5 0001 / 00D5 0002 |
| STANDING_REMINDER_SET / _GET | 0060 0001 / 0060 0002 |
| WATER_REMINDER_SET / _GET | 0061 0001 / 0061 0002 |
| TASK_REMINDER_SET / _RET ⚠️ | FFFF 9072 / FFFF A072 |
| 이름 | cmd1,cmd2 |
|---|
| GOALS_SET / _ACK | 005E 0001 / 005E 0003 |
| UNIT_LENGTH / _ACK | FFFF 9067 / FFFF A067 |
| UNIT_TEMPERATURE / _ACK | FFFF 9068 / FFFF A068 |
| TIME_FORMAT / _ACK | 005F 0001 / 005F 0003 |
| WAKE_ON_WRIST_RAISE / _GET / _ACK | 0062 0001 / 0062 0002 / 0062 0003 |
| LANGUAGE_SET / _RET | FFFF 9058 / FFFF A06B |
| HEART_MONITORING_ENABLED_SET / _GET | 009B 0001 / 009B 0002 |
| HEART_MONITORING_ALERTS | FFFF 9059 |
| DO_NOT_DISTURB / _GET | 0099 0001 / 0099 0002 |
| SPORTS_SET / _GET | 00DC 0001 / 00DC 0002 |
| SPORT_LINKAGE_SET / _RET | FFFF 9076 / FFFF A076 |
| SPORT_DATA_SYNC 🔎 (실시간 심박수/칼로리/걸음수) | FFFF 9078 / FFFF A078 |
| FEMALE_CYCLE_SET / _RET | FFFF 9071 / FFFF A071 |
| SLEEP_CONFIG_SET / _RET (목표 시간) | FFFF 9074 / FFFF A074 |
| WORLD_CLOCK_GET | FFFF 906F |
| WORLD_CLOCK_DST_SET / _RET | FFFF 9083 / FFFF A083 |
| VITALITY_GET / _RET | FFFF 9079 / FFFF A079 |
| VITALITY_SW_SET / _RET | FFFF 9070 / FFFF A070 |
| 이름 | cmd1,cmd2 |
|---|
| DIAL_COMMAND_SET / _RET (목록/재정렬/선택) | FFFF 9055 / FFFF A055 |
| DIAL_CONFIG_SET / _RET | FFFF 9075 / FFFF A075 |
| CHANGE_DIAL (⚠️ 1.0.0.73에서 비활성 — 사용하지 마세요) | 009F 0001 |
| QUICK_CARD_SET/GET / _RET (둘 다 906D) | FFFF 906D / FFFF A06D |
| 이름 | cmd1,cmd2 |
|---|
| ACTIVITY_FETCH_1 / _2 | FFFF 8005 / FFFF 9057 |
| ACTIVITY_FETCH_ACK_1 / _2 | FFFF 0005 / FFFF A057 |
| ACTIVITY_DATA | 0056 0001 |
| SLEEP_DATA / _GET | 0058 0001 / 0058 0002 |
| SPO2 | 0055 0001 |
| STRESS | 009D 0001 |
| HEART_RATE_MANUAL_AUTO | 0053 0001 |
| HEART_RATE_RESTING | 00DA 0001 |
| HEART_RATE_WORKOUT | 00E0 0001 |
| SKIN_TEMP_HISTORY 🔎 (이 SKU에서는 비어 있음) | 0155 0001 / 0155 0002 |
| WORKOUT_SUMMARY / _V3 | 0057 0001 / 0160 0001 |
| WORKOUT_GPS | FFFF A05A |
| 도메인 | INIT1 요청/응답 | INIT2 요청/응답 | CHUNK 요청/쓰기 | FINISH 승인1/승인2 |
|---|
| 워치페이스(사진) | 8052/0052 | 9063/A063 | A064/9064 | A065/9065 |
| 워치페이스(구조화/전환) | 8052/0052 | 9075/A075 | A064/9064 | A065/9065 |
| 펌웨어 | 9052/A052 | 9040/A040 | A042/9042 | A041/9041 |
| AGPS/EPO | 905E/A05E | — | A05F/905F | A060/9060 |
| 오프셋 | 크기 | 필드 |
|---|
| 0 | 4 | 타임스탬프(에포크 초) |
| 4 | 4 | 걸음 수 |
| 8 | 4 | 거리(m) |
| 12 | 4 | 칼로리 |
| 16 | 16 | 예약됨(0으로 관찰됨) |
| 오프셋 | 크기 | 필드 |
|---|
| 0 | 4 | 세션 시작(에포크, UTC) |
| 4 | 4 | 기상(에포크, UTC) |
| 8 | 2 | 총 깊은 수면(s) |
| 10 | 2 | 총 핵심 수면(s) |
| 12 | 2 | 총 REM(s) |
| 14 | 2 | 총 각성(s) |
| 16 | 2 | ⚠️ [불확실] (세션 ID/점수? 관찰된 값이 레코드 합계와 일치하지 않음) |
ac 49 1f 0100D5 0001) ✅: N × 57바이트 = name(32) ‖ phone(25). 시계 UI는 최대 20개까지 표시합니다.0063 0001) ✅ — Gadgetbridge 수정(레이블을 마지막에 넣고 0xff-패딩 —
잘못됨). 알람당 40바이트, 빅 엔디언:
secondsOfDay(i32) ‖ index(u8) ‖ enabled(u8) ‖ repetition-bitmask(u8) ‖ flag(u8) ‖ label[32] UTF-8.
레이블은 오프셋 8에 있으며 시계에 표시됩니다. repetition = 요일 비트마스크(0 = 일회성);
flag는 ⚠️ [불확실] (일회성 표시?). 예 (13:30, idx 2): 0000bdd8 02 01 15 00 "Alarm…".005E 0001) ✅ — 공식 앱과 참조 구현은 10바이트, 빅 엔디언 DailyTargetBean v1을 사용합니다:
steps(u32 BE) ‖ distance_m(u32 BE) ‖ calories_kcal(u16 BE).
(이것은 Gadgetbridge 형식입니다; 시계가 "무시한다"는 이전 보고는 세션 해독 버그였으며, 페이로드 문제가 아닙니다.)
🔎 펌웨어 RE는 더 긴 29바이트 확장 변형도 보여줍니다(추가 sleep_min/exercise_min/stand_h + 6개 활성화 플래그,
모두 flag(u16 LE) 접두사 뒤에 u32 BE, 범위 적용: 걸음수 2000–30000, 거리 1000–99000, 칼로리 100–5000,
수면 360–720, 운동 30–90, 서 있음 6–16) — 앱의 기본 경로가 아닙니다; 추가 목표가 필요하지 않다면
10바이트 형식을 선호하십시오.0060/0061 0001) ✅: 11바이트:
enabled(1) ‖ threshold_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE). UI에 표시되는 "활성 창
08:00–22:00"는 펌웨어의 고정된 기본값이며 페이로드에 포함되지 않습니다.00DC 0001) ✅: count(1) = 36개 슬롯 ‖ activityTypeCode[36] (활성 코드 다음 00
패딩). 시계의 운동 메뉴에 표시될 스포츠를 선택합니다.009B 0001) ✅: kind 바이트 — 01 = 24/7 심박수, 02 = SpO₂,
04 = 스트레스(30분마다 측정).FFFF 9059) ✅: 비활성화 = 00; 활성화 =
01 ‖ hrLow ‖ hrHigh ‖ sportHrHigh ‖ spo2Low ‖ 00 00 00 00 (0/255 경계 = "제한 없음").FFFF 9071) ✅: 01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ period(1) ‖ cyclePeriod(1) ‖ cycleStartDate(u32) ‖ markStart(u32) ‖ markEnd(u32) (캡처: period=5, cyclePeriod=0x1c=28).FFFF 9073) ✅: TLV — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]…
(7개의 기본 응답 캡처 및 해독됨).FFFF 906F) ✅: 이름이 아닌 숫자 도시 ID를 전송합니다(01 ‖ count ‖ cityId(2 BE)…);
시계는 내부 테이블에서 ID를 매핑합니다. DST 구성 FFFF 9083 =
count ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ start(u32 LE) ‖ end(u32 LE)]….FFFF 905C, 131 B) ✅: state(1: 0=없음/1=일시정지/2=재생) ‖ volume(1) ‖ volumeMax(1) ‖ track(64) ‖ artist(64). 시계는 또한 MUSIC_BUTTON (A05D)을 다시 보냅니다.FFFF 906B, 199 B) ✅ — 이것을 사용하십시오: 7×9바이트 일 + 24×2바이트 시간 +
도시(32) + 7×8바이트 일출/일몰(LE). 온도는 (temp_c + 100) & 0xFF로 인코딩됩니다.
⚠️ 동일한 페이로드가 WEATHER_SET_2 (0066 0001)로 전송되면 Pro 2에서 날씨 위젯을 업데이트하지
않습니다 — 항상 906B를 사용하십시오. (도시 문자열은 또한 입증된 데이터 하이재킹 벡터입니다 — §13 참조.)005D 0001) ✅: 페이로드 0x01 → 시계가 울림/진동(+ ACK 005D 0003).FFFF 906A) ✅ — 빅 엔디언, 경도 우선: 16바이트
ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. 실제 위치에 대해 검증됨.FFFF A05A) ✅ — 리틀 엔디언, 경도 우선: 12바이트
ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).FFFF 8004): §7 참조.| cf | bpp | 래스터 (LZ4 후) | 용도 |
|---|
| 4 | 2 | RGB565-LE | 불투명 배경 (FULL/THUMB) |
| 5 | 3 | RGB565-LE (2 B) + 알파 (1 B) 픽셀 당 | 안티앨리어싱 스프라이트 (글리프, 손, 아이콘) |
| 13 (0x0d) | 0.5 | 4비트 알파 마스크; 펌웨어가 런타임에 색조 적용 | 숫자-글리프 아틀라스 |
| 24 (0x18) | 4 | RGBA8888 | 풀 컬러 레이어 (항상 켜진 aodImage 포함) |
| 1 | — | JPEG/JFIF (ff d8 ff), 모든 디코더로 추출 | 드문 애니메이션 프레임 |
0x61NotEnvelope0aChildOverflow−18/−160a.bin에 없음 ⚠️ 구성 가능한 컴플리케이션은 동일한 (x,y)에 쌓인 N개의 0x68 그룹 노드로 작성되며, 각각 다른 소스에 바인딩됩니다(275의 두 원: 슬롯당 0x1e/0x6a/0x48/0x24/0x19 — 표시된 측정 항목이 아닌 옵션/스타일 ID); 두 원은 rect와 인스턴스 바이트(0x79/0x7a)를 제외하고 바이트가 동일합니다. 어떤 측정 항목이 표시되는지(STEPS vs KCAL vs …)는 장치 RAM/설정 상태이므로 정적 미리보기가 파일에서 이를 재현할 수 없습니다 — 최선의 노력만 가능.(446,0)의 bpm 텍스트, 275/302/325/365/375에서 확인)은 펌웨어가 기본 보기에서 그리지 않는 슬롯입니다 — 그 값은 캔버스 가장자리 전에 들어갈 수도 없습니다. 미리보기에서 숨김 처리합니다.aod0x22@69,2090x60 img_number (cnt=10) — 소스가 −5에 있음, 순방향 하나씩 어긋남 ✅ §11.8과 동일한 하나씩 어긋남이지만 시계가 아닌 숫자에 해당: "Gradient"의 날짜는 (203,80) 상단 중앙에 소스 0x17로 위치했지만, 순방향 82-스캔이 인접한 포인터의 각도 게터(0x0a)와 포인터 위치를 가져와서 → 숫자가 포인터 위치에 잘못된 소스로 렌더링되었습니다. 수정: 0x60 래퍼 내의 61 0a 00 img_number에 대해, 순방향 소스가 숫자에 불가능한 경우(소스-0 또는 포인터 각도 게터 0x0a/0e/12/70/71/72) 그리고 −18/−16 위치가 유효하고 0이 아닌 경우(0이 아닌 가드는 relX=0인 그룹 자식 숫자를 건너뜀) −5/−18/−16을 신뢰합니다.0x17 = 날짜(월의 일), 0x24 = 온도 — 구별됨. 다이얼 340은 둘 다 사용합니다(0x17 "Jun 09"와 별도의 0x24 온도), 따라서 0x17은 날짜이고 온도가 아닙니다. 시계가 0x17 슬롯에 온도를 표시하는 다이얼은 사용자 구성 컴플리케이션(장치 상태)이며 파일 기본값이 아닙니다.| 십진수 | 카드 | 십진수 | 카드 |
|---|
| 0 | 빈 슬롯 | 49–53,97–98 | 날씨 |
| 1–6 | 걸음 수 | 54–58 | 타이머 |
| 7–12 | 칼로리 | 59–62 | 브리딩 |
| 13–18 | 서 있기 | 63,65–67 | 스톱워치 |
| 19–24 | 중간 활동 | 68–71 | 배터리 |
| 25–30 | 심박수 | 72–76 | 최근 |
| 31–36 | SpO₂ | 77–81 | 연락처 |
| 37–42 | 스트레스 | 82–86 | 다이얼/전화 |
| 43–48 | 수면 | 87–91 | 스포츠 (sportId ≠ 0) |
| 92 | 음악 | 93/94/96 | 활동 기록 / PAI / 사이클 |