Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
CMF-Watch-Pro-2-BLE-Protocol — CMF Watch Pro 2용 역설계된 BLE 프로토콜로, GATT 레이아웃, AES-128-CBC 암호화 명령 프레임, 인증 핸드셰이크 및 대체 컴패니언 앱 개발을 위한 건강 데이터 동기화를 문서화합니다. | Kitploit
도구/GitHubGitHub/joshuapassos/cmf-watch-pro-2-ble-protocol
Embedded Systems SecurityBluetooth SecurityIoT SecurityReverse EngineeringWireless SecurityCryptographyMobile SecurityHardware & IoT SecurityFirmware Analysis

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유
GitHubjoshuapassos/cmf-watch-pro-2-ble-protocol

CMF-Watch-Pro-2-BLE-Protocol

CMF Watch Pro 2용 역설계된 BLE 프로토콜로, GATT 레이아웃, AES-128-CBC 암호화 명령 프레임, 인증 핸드셰이크 및 대체 컴패니언 앱 개발을 위한 건강 데이터 동기화를 문서화합니다.

저장소 보기웹사이트
325일 전아직 검토되지 않음

CMF Watch Pro 2 — BLE 프로토콜 (리버스 엔지니어링)

비공식. 이 문서는 CMF Watch Pro 2(CMF by Nothing)의 Bluetooth Low Energy (BLE) 프로토콜을 리버스 엔지니어링을 통해 재구성한 것으로, 대체 동반 앱을 위한 것입니다. Nothing/CMF와 제휴하거나 보증하지 않습니다. 사용에 따른 책임은 본인에게 있습니다.

프레임 헤더 및 명령어 코드는 모두 빅엔디안(big-endian) 입니다. 명령어 페이로드 내부의 정수는 별도로 명시되지 않는 한 리틀엔디안(little-endian)입니다(이것은 기기 펌웨어를 반영합니다). 예외 사항(GOALS_SET, GPS_PUSH, 벌크 전송 오프셋/길이는 빅엔디안)에 주의하세요.

신뢰도 표시

아래의 명백하지 않은 모든 주장에는 확립된 방법이 태그되어 있습니다.

  • ✅ 기기에서 검증됨 — 복호화된 실시간 캡처에서 관찰되거나 실제 시계에 대해 실행됨.
  • 🔎 펌웨어/APK RE에서 추출 — 펌웨어(1.0.0.73) 또는 공식 APK(3.5.7)를 디컴파일하여 추출함; 코드와 일치하지만 런타임 테스트되지 않음.
  • ⚠️ [불확실] — 추론된 것으로, 확인되지 않음; 틀릴 수 있음.

모든 캡처에 대한 테스트 기기: CMF Watch Pro 2-5485, 펌웨어 1.0.0.73, 일련번호 CI04102520008192, MCU Actions ATS3089C(Cortex-M4), 화면 466×360.


1. GATT 레이아웃

휴대폰은 GATT 클라이언트이고, 시계는 주변 기기로 CMF Watch Pro 2-XXXX(4자리 16진수)로 광고합니다.

각 CCCD(00002902-…)에 01 00을 기록하여 알림을 활성화합니다. 명령 채널(fff1/fff2)은 아래의 프레임 프로토콜을 전달합니다. 쉘 채널(77d4…)은 일반 AT 스타일 텍스트(예: AT GETSECRET; §14 참조)를 전달합니다. 데이터 채널(02f0…)은 명령 채널의 제어 명령어로 조정되는 대용량 바이너리 블롭(시계 화면, 펌웨어, AGPS)을 전달합니다.

✅ 전체 실제 세션이 단일 명령 채널에서 실행되었습니다. 160초의 많은 사용 캡처 동안 명시적인 OTA/시계 화면 전송 중을 제외하고는 데이터/펌웨어 또는 쉘 채널에서 트래픽이 없었습니다.


2. 프레임 형식(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 ____________________________/

root@kitploit:~
- `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가 실시간 프레임을 복호화함.


4. 인증 / 페어링 핸드셰이크

두 진입 경로는 동일한 논스/확인 꼬리(tail)를 공유합니다.

4.1 첫 페어링 (기기 비밀키 보유)```

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

root@kitploit:~
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

✅ 재연결 순서(셸 트래픽 없음)는 실제 캡처에서 그대로 관찰되었습니다.

4.3 인증 후 초기화(2단계)

⚠️→✅ 데이터 쿼리 전에 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).

4.4 GET → SET 에코 패턴(✅)

대부분의 설정에 대해 별도의 "읽기" 명령어는 존재하지 않습니다. *_GET(cmd2 = 0x0002, 페이로드 0xA5)을 보내면 시계는 현재 값을 포함하여 SET 명령어(cmd2 = 0x0001)로 응답합니다. SET 명령어는 cmd2 = 0x0003과 빈 본문으로 승인됩니다.


5. 평문 vs 암호화

키가 설정되면 프레임은 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가 필요합니다.


6. 명령어 참조 (cmd1, cmd2)

GET/SET/REQUEST = 폰→시계; RET/REPLY/ACK/RESPONSE/DATA = 시계→폰.

세션/장치

인증

알림/통화/찾기

음악

이름cmd1,cmd2
MUSIC_INFO_SET / _ACKFFFF 905C / FFFF A05C
MUSIC_BUTTONFFFF 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/908B ChatGPT 상태/지원)는 Java 레이어가 아닌 앱의 Hermes 바이트코드에서 처리됩니다. 해당 헤더는 캡처에 나타나지만 페이로드 의미는 ⚠️ [불확실] 합니다.

대량 데이터 전송(데이터 채널)

워치페이스/펌웨어/AGPS는 init → 청크 요청/청크 쓰기 루프 → 완료 승인을 사용합니다:

(모든 cmd1 = FFFF.) 시계는 DATA_CHUNK_REQUEST_*(offset, length)를 내보내 루프를 구동합니다. (offset/length = u32 빅 엔디언); 폰은 데이터 특성에 payload[offset..offset+length]를 포함하는 DATA_CHUNK_WRITE_*로 응답합니다. 자세한 내용은 §11–§12를 참조하십시오.


7. 시간 및 시간대

TIME (FFFF 8004) 페이로드 = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). 인증 직후 전송되어 시계가 현지 시간을 표시하도록 합니다(데이터 쿼리 차단 해제 — §4.3 참조).

⚠️ 시계의 건강 타임스탬프는 UTC입니다. 동반 앱은 현지 UTC 오프셋을 추가하여 현지 달력 일/시간을 도출해야 합니다. (원시 UTC 일로 건강을 버킷팅하면 잘못된 현지 시간에 일이 넘어갑니다.)

TIME_FORMAT (005F 0001) 페이로드 = 1바이트: 00 = 24시간, 01 = 12시간.


8. 건강 동기화

  1. 폰이 ACTIVITY_FETCH_1을 전송; 시계가 ACTIVITY_FETCH_ACK_1로 응답(첫 번째 바이트 01 ⇒ 준비 완료).
  2. 폰이 ACTIVITY_FETCH_2를 전송; 그러면 시계가 데이터 프레임 버스트를 푸시합니다: ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].
  3. 각 항목은 분당 샘플/세션으로 구문 분석되어 현지 일별로 집계됩니다.

동기화는 순차적입니다(TIME을 따라야 하며, 시계는 ACK_2 후에 스트림을 해제합니다). 단일 버스트가 아닙니다. 무거운 세션은 약 160초에 ~170–210개의 알림 프레임을 푸시합니다. ✅

8.1 활동 기록 — ACTIVITY_DATA (각 32바이트, LE) ✅

칼로리 단위: 활동 칼로리는 cal(그램-칼로리)로 보고됩니다. 일일 합계를 1000으로 나누어 kcal을 얻습니다. (운동 요약 칼로리는 대조적으로 이미 kcal입니다.)

8.2 심박수/SpO₂/스트레스 샘플 ✅

  • 수동/자동 심박수, 운동 심박수, SpO₂, 스트레스 = 각 8바이트: 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.

8.3 수면 — SLEEP_DATA (18바이트 헤더 + N × 8바이트 레코드) ✅

하나의 SLEEP_DATA = 한 수면 세션; 밤에는 여러 개가 포함될 수 있습니다(미세 각성이 세션을 분할).

헤더:

각 8바이트 레코드: timestamp(u32) ‖ duration_s(u16) ‖ stage(u16). 단계 코드: 1 = 깊은 수면, 2 = 핵심/얕은 수면, 3 = REM, 4 = 각성. ✅ 전체 밤(두 세션, D/C/R/A 합계 조정)에 대해 검증됨.

8.4 운동 요약 — 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 운동의 원시 캡처 한 번이 필요합니다.


9. 선택된 명령어 페이로드

문자열은 UTF-8이며, 필드 크기로 바이트 단위 잘림(잘림은 멀티바이트 문자를 분할할 수 있으며, 이는 펌웨어의 s.encode()[:max] 동작과 일치함); 짧은 필드는 오른쪽에 0으로 패딩됩니다.

  • APP_NOTIFICATION (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.
  • BATTERY (005C 0001) ✅: 응답 = level(1) ‖ charging(1) (예: 3b 00 = 59 %, 충전 중 아님).
  • SERIAL_NUMBER_RET (00DE 0001) ✅: len(1) ‖ ASCII (예: 10 + "CI04102520008192").
  • USER_INFO (0095 0001) ✅: height_cm(1) ‖ weight_kg(1) ‖ age(1) ‖ gender(1: 1=남성) (예: = 172 cm / 73 kg / 31 / 남성).

10. 구현 참고 사항 및 특징

  • 코덱에 시스템 시계 없음: 인코더는 now/utc_offset을 명시적 매개변수로 사용합니다 (결정적이고 테스트 가능). 전송 계층이 실제 시간을 제공합니다.
  • TIME이 모든 것을 차단합니다(§4.3) — 먼저 전송하지 않으면 시계가 데이터 쿼리에 응답하지 않습니다.
  • 평문 CRC 계산(§2)은 잘못되기 쉽습니다 — 평문 프레임은 CRC를 알리지만 생략합니다.
  • 엔디언: 헤더 + 명령어 BE; 페이로드 정수 LE; 예외 — GOALS_SET 및 GPS_PUSH는 빅 엔디언이며, 대량 전송 오프셋/길이는 빅 엔디언입니다.
  • MTU: 청크 크기는 암호화된 청크가 16바이트 AES 블록에 정렬되도록 계산됩니다.
  • authkey는 영구 저장 가능(첫 번째 페어링 후 저장); sessionKey는 연결마다 있으며 재연결할 때마다 시계 nonce에서 파생됩니다.

11. 워치 페이스 / 다이얼 — 제작

시계는 (a) 사진/사용자 지정 다이얼(배경 이미지 + 펌웨어가 그리는 디지털 시계)과 (b) 구조화된 다이얼(내장/스토어 페이스: 배경과 위치 지정된 스프라이트 레이어, 시침, 텍스트 위젯)을 지원합니다. 둘 다 §6의 init → 청크 루프를 통해 데이터 채널을 통해 전송됩니다.

실제로 작동하는 것(✅ 라이브 검증됨): 모든 이미지에서 사진 다이얼을 제작하여 설치; 103개의 스토어 다이얼을 오프라인으로 설치; 구조화된 다이얼 리스킨(배경 또는 비배경 스프라이트 교체) 및 레이어 이동; 활성 페이스 재정렬/전환; 그리고 처음부터 구조화된 다이얼 제작 — 0x20 장면 봉투는 디코딩되었으며 빌더가 구현됨(§11.7), 모든 103개 스토어 다이얼을 바이트 단위로 왕복하고 펌웨어 자체 유효성 검사기를 통과하는 합성 컨테이너를 내보내는 것이 오프라인으로 입증됨. 🟡 유일하게 입증되지 않은 단계는 9075를 통해 장치에서 처음부터 합성 렌더링을 관찰하는 것입니다(구조적 오프라인 증명이 이미 0a 거부의 원인을 다루고 있음). 코덱 또는 전송 장벽이 없으며 공급업체 도구 체인이 필요하지 않습니다. 이전의 "구조화된 렌더링은 RES-pack에 고정되어 있음 / BLE로 불가능" 및 "cf=0x1f 서버 측 코덱" 주장은 잘못되었습니다(오프셋+바이트-픽셀 버그) — 펌웨어는 보내는 파일에서 데이터 기반으로 구조화된 다이얼을 렌더링합니다.

11.1 다이얼 관리 — DIAL_COMMAND (9055 / a055) ✅

  • type 0 = 목록 쿼리. 응답 a055 = result(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. 예: 01 05 06 07 … = 활성 #5, 다이얼 6개, 최대 7개.
  • type 1 = 재정렬/활성 선택: 대상 다이얼을 인덱스 0에 두고 전체 목록을 다시 보냅니다 (공식 앱이 페이스를 전환하는 방법입니다; 전용 "활성 설정" 명령어는 없습니다).
  • 다이얼 삭제 = ID 없이 목록을 다시 보냅니다.
  • CHANGE_DIAL (009F 0001)는 fw 1.0.0.73에서 비활성(상수를 반환하며 전환하지 않음) — 사용하지 마십시오.

11.2 전송 흐름 ✅```

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)

root@kitploit:~
완료 응답 바이트: `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

root@kitploit:~
`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)

root@kitploit:~
`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 거부 원인을 다루고 있습니다.

11.8 렌더링 정확도 개선 (2026-07-02, 다이얼 275 "SlopeTime")

wfweb 렌더를 공식 스토어 썸네일(전체 103개 다이얼에 대한 픽셀 오라클)과 교차 검증하여 네 가지 격차를 해소했습니다:

  • 드로어블/포인터 X/Y는 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 쓰기 오프셋 수정으로 마지막 불일치가 제거됨).

11.9 AOD 컨테이너 건너뛰기 + 독립형 img_number 소스 (2026-07-03, 다이얼 "Gradient")

  • 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개 다이얼에서 바이트 정확하게 유지됩니다.

11.10 img_number DIGIT COUNT — 40 01 00 XX 바이트 (✅ 펌웨어 확인)

img_number가 그리는 자릿수는 필드 레코드의 단일 바이트입니다 — 요소의 40 01 00 XX 속성 서브레코드의 데이터 바이트 XX (61 [count][base][glyph-ids] 프레임 테이블 뒤에 위치한 0x40 서브레코드):

  • 낮은 니블 XX & 0x0F = 자릿수 슬롯 개수 (0 ⇒ 펌웨어 기본값 7).
  • 비트 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개 다이얼에서 바이트 정확.


12. 대량 전송 및 OTA 세부사항

전송 테이블은 §6에 있습니다. 추가 확인된 사항:

  • AGPS/EPO ✅: 첫 번째 기록된 청크는 ASCII 헤더 000000010000…로 시작합니다. 전체 init → [A05F ↔ 905F]×N → finish 루프가 와이어에서 관찰됨(~892 청크).
  • 펌웨어 OTA (9040–9042, finish 9041) 🔎: 구조 매핑됨; INIT2 페이로드 = 버전 바이트(예: 0b 00 00 39 = 11.0.0.57). 현장 테스트되지 않음(앱이 여기서 FW 업데이트를 비활성화). 펌웨어 이미지는 서명되지 않은 것으로 보임 — 무결성은 CRC32만 있음 (RE에서 비대칭 서명 관찰되지 않음).
  • ⚠️ OTA와 FACTORY_RESET (009A 0001)가 인증된 세션을 공유하므로, 단일 유효한 BLE 인증으로 시계를 초기화하거나 (원칙적으로) 벽돌로 만들 수 있습니다. 주의해서 다루십시오.

13. 센서

✅ BLE를 통해 노출된 하드웨어:

  • 광학 PPG — 심박수 (수동/자동/운동/휴식), SpO₂, HRV 기반 스트레스.
  • 3축 가속도계 — 걸음 수, 거리, 칼로리, 수면 단계, 손목 올리기, 케이던스.
  • GNSS/GPS (AGPS 지원) — 운동 트랙(WORKOUT_GPS) 및 위치 푸시(GPS_PUSH).

기압계/고도계, 나침반, 자이로스코프, 피부/체온 센서는 없습니다. 내부 NTC 서미스터(보드/배터리 온도)는 존재하지만 AT 채널을 통해서만 읽을 수 있습니다(AT GETNTCTEMP, §14) — 이 SKU에서 0155 피부 온도 기록 스트림은 비어 있습니다.

데이터 위젯 하이재킹 (실제 컴플리케이션/데이터 바인딩 API 없음 — §11.5 참조): 시계의 기존 텍스트 필드를 재사용하여 한눈에 볼 수 있는 외부 데이터를 표시할 수 있습니다. 입증됨 ✅: 날씨 도시 문자열(WEATHER_SET_1, 예: "BRA 2x1 ARG"이 위젯에 나타남) 및 음악 트랙/아티스트 필드; 연락처 목록(20 × 이름[32]+번호[25])은 스크롤 가능한 데이터 패널로 작동합니다. 모두 푸시 방식이며 지속적인 컴플리케이션이 아닙니다.


14. AT 공장 / 쉘 채널 (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를 에코하지 않을 수 있습니다 — 경우에 따라 확인.


15. 펌웨어 게이트 / 사용 불가능 기능 (🔎 펌웨어 RE)

일부 기능은 펌웨어에 존재하지만 SKU/지역에 의해 비활성화되어 있으며 휴대폰/BLE에서 접근할 수 없습니다 — 펌웨어 수정이 필요하며, 이는 여기서 다루지 않습니다:

  • ChatGPT 음성 — 게이트 = ux2sys 피처 ID 0x9e, 부팅 시 NVRAM/EFUSE/지역에서 시드됨; 이 SKU에서 지원 플래그 908b = 00. 휴대폰, 계정 또는 BLE로 영향 불가능 (실험 + RE로 확인). 앱은 중계기일 뿐; 오디오는 휴대폰 → Nothing 클라우드로 이동.
  • 혈압 — 완전한 서브시스템이 펌웨어에 존재하지만 SKU/지역에 의해 꺼져 있음.
  • Alipay / NFC 결제 — 전체 UI 존재, 중국 SKU 전용.
  • 하드웨어/펌웨어에 없음: ECG, SOS/긴급, 일반 NFC.

16. 컴플리케이션 게터 테이블 (🔎 펌웨어 내부 참조)

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-00805f9b34fb0000fff2-…Write
명령 알림0000fff0-…0000fff1-…Notify
쉘 쓰기 (AT)—77d4ff01-2fe2-2334-0d35-9ccd078f529cWrite
쉘 알림 (AT)—77d4ff02-…Notify
벌크 데이터 쓰기—02f00000-0000-0000-0000-00000000ffe1Write
벌크 데이터 알림—02f00000-…ffe2Notify
이름cmd1,cmd2
TIMEFFFF 8004
FIRMWARE_VERSION_GET / _RETFFFF 8006 / FFFF 0006
SERIAL_NUMBER_GET / _RET00DE 0002 / 00DE 0001
BATTERY005C 0001
TRIGGER_SYNC005C 0002
USER_INFO_SET / _RET 🔎✅0095 0001 / 0095 0003
FACTORY_RESET009A 0001
DEVICE_REBOOT 🔎FFFF 9080
RESOLUTION_GET 🔎 (→ 466×360)FFFF 907F
GPS_PUSH / _RETFFFF 906A / FFFF A06A
UNBIND_SET / _RETFFFF 907A / FFFF A07A
이름cmd1,cmd2
AUTH_PHONE_NAMEFFFF 8049
AUTH_WATCH_MACFFFF 0049
AUTH_PAIR_REQUEST / _REPLYFFFF 8047 / FFFF 0048
AUTH_NONCE_REQUEST / _REPLYFFFF 804B / FFFF 004C
AUTHENTICATED_CONFIRM_REQUEST / _REPLYFFFF 804D / FFFF 0004
AUTH_FAILEDFFFF A061
이름cmd1,cmd2
APP_NOTIFICATION0065 0001
INCOMING_CALL ⚠️0064 0001
CALL_REMINDER_REQUEST / _RESPONSEFFFF 9066 / FFFF A066
FIND_PHONE005B 0001
FIND_WATCH005D 0001
FIND_WATCH_TOGGLEFFFF 9069
SMS_MESSAGE_PUSH / _RETFFFF 906E / FFFF A06E
QUICK_REPLY_SET / _RETFFFF 9073 / FFFF A073
이름cmd1,cmd2
ALARMS_SET / _GET0063 0001 / 0063 0002
CONTACTS_SET / _GET00D5 0001 / 00D5 0002
STANDING_REMINDER_SET / _GET0060 0001 / 0060 0002
WATER_REMINDER_SET / _GET0061 0001 / 0061 0002
TASK_REMINDER_SET / _RET ⚠️FFFF 9072 / FFFF A072
이름cmd1,cmd2
GOALS_SET / _ACK005E 0001 / 005E 0003
UNIT_LENGTH / _ACKFFFF 9067 / FFFF A067
UNIT_TEMPERATURE / _ACKFFFF 9068 / FFFF A068
TIME_FORMAT / _ACK005F 0001 / 005F 0003
WAKE_ON_WRIST_RAISE / _GET / _ACK0062 0001 / 0062 0002 / 0062 0003
LANGUAGE_SET / _RETFFFF 9058 / FFFF A06B
HEART_MONITORING_ENABLED_SET / _GET009B 0001 / 009B 0002
HEART_MONITORING_ALERTSFFFF 9059
DO_NOT_DISTURB / _GET0099 0001 / 0099 0002
SPORTS_SET / _GET00DC 0001 / 00DC 0002
SPORT_LINKAGE_SET / _RETFFFF 9076 / FFFF A076
SPORT_DATA_SYNC 🔎 (실시간 심박수/칼로리/걸음수)FFFF 9078 / FFFF A078
FEMALE_CYCLE_SET / _RETFFFF 9071 / FFFF A071
SLEEP_CONFIG_SET / _RET (목표 시간)FFFF 9074 / FFFF A074
WORLD_CLOCK_GETFFFF 906F
WORLD_CLOCK_DST_SET / _RETFFFF 9083 / FFFF A083
VITALITY_GET / _RETFFFF 9079 / FFFF A079
VITALITY_SW_SET / _RETFFFF 9070 / FFFF A070
이름cmd1,cmd2
DIAL_COMMAND_SET / _RET (목록/재정렬/선택)FFFF 9055 / FFFF A055
DIAL_CONFIG_SET / _RETFFFF 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 / _2FFFF 8005 / FFFF 9057
ACTIVITY_FETCH_ACK_1 / _2FFFF 0005 / FFFF A057
ACTIVITY_DATA0056 0001
SLEEP_DATA / _GET0058 0001 / 0058 0002
SPO20055 0001
STRESS009D 0001
HEART_RATE_MANUAL_AUTO0053 0001
HEART_RATE_RESTING00DA 0001
HEART_RATE_WORKOUT00E0 0001
SKIN_TEMP_HISTORY 🔎 (이 SKU에서는 비어 있음)0155 0001 / 0155 0002
WORKOUT_SUMMARY / _V30057 0001 / 0160 0001
WORKOUT_GPSFFFF A05A
도메인INIT1 요청/응답INIT2 요청/응답CHUNK 요청/쓰기FINISH 승인1/승인2
워치페이스(사진)8052/00529063/A063A064/9064A065/9065
워치페이스(구조화/전환)8052/00529075/A075A064/9064A065/9065
펌웨어9052/A0529040/A040A042/9042A041/9041
AGPS/EPO905E/A05E—A05F/905FA060/9060
오프셋크기필드
04타임스탬프(에포크 초)
44걸음 수
84거리(m)
124칼로리
1616예약됨(0으로 관찰됨)
오프셋크기필드
04세션 시작(에포크, UTC)
44기상(에포크, UTC)
82총 깊은 수면(s)
102총 핵심 수면(s)
122총 REM(s)
142총 각성(s)
162⚠️ [불확실] (세션 ID/점수? 관찰된 값이 레코드 합계와 일치하지 않음)
ac 49 1f 01
  • CONTACTS_SET (00D5 0001) ✅: N × 57바이트 = name(32) ‖ phone(25). 시계 UI는 최대 20개까지 표시합니다.
  • ALARMS_SET (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…".
  • GOALS_SET (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바이트 형식을 선호하십시오.
  • STANDING_REMINDER / WATER_REMINDER (0060/0061 0001) ✅: 11바이트: enabled(1) ‖ threshold_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE). UI에 표시되는 "활성 창 08:00–22:00"는 펌웨어의 고정된 기본값이며 페이로드에 포함되지 않습니다.
  • SPORTS_SET (00DC 0001) ✅: count(1) = 36개 슬롯 ‖ activityTypeCode[36] (활성 코드 다음 00 패딩). 시계의 운동 메뉴에 표시될 스포츠를 선택합니다.
  • HEART_MONITORING_ENABLED (009B 0001) ✅: kind 바이트 — 01 = 24/7 심박수, 02 = SpO₂, 04 = 스트레스(30분마다 측정).
  • HEART_MONITORING_ALERTS (FFFF 9059) ✅: 비활성화 = 00; 활성화 = 01 ‖ hrLow ‖ hrHigh ‖ sportHrHigh ‖ spo2Low ‖ 00 00 00 00 (0/255 경계 = "제한 없음").
  • FEMALE_CYCLE (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).
  • QUICK_REPLY (FFFF 9073) ✅: TLV — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]… (7개의 기본 응답 캡처 및 해독됨).
  • WORLD_CLOCK (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)]….
  • MUSIC_INFO_SET (FFFF 905C, 131 B) ✅: state(1: 0=없음/1=일시정지/2=재생) ‖ volume(1) ‖ volumeMax(1) ‖ track(64) ‖ artist(64). 시계는 또한 MUSIC_BUTTON (A05D)을 다시 보냅니다.
  • WEATHER_SET_1 (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 참조.)
  • FIND_WATCH (005D 0001) ✅: 페이로드 0x01 → 시계가 울림/진동(+ ACK 005D 0003).
  • GPS_PUSH (FFFF 906A) ✅ — 빅 엔디언, 경도 우선: 16바이트 ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. 실제 위치에 대해 검증됨.
  • WORKOUT_GPS (FFFF A05A) ✅ — 리틀 엔디언, 경도 우선: 12바이트 ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).
  • TIME (FFFF 8004): §7 참조.
  • cfbpp래스터 (LZ4 후)용도
    42RGB565-LE불투명 배경 (FULL/THUMB)
    53RGB565-LE (2 B) + 알파 (1 B) 픽셀 당안티앨리어싱 스프라이트 (글리프, 손, 아이콘)
    13 (0x0d)0.54비트 알파 마스크; 펌웨어가 런타임에 색조 적용숫자-글리프 아틀라스
    24 (0x18)4RGBA8888풀 컬러 레이어 (항상 켜진 aodImage 포함)
    1—JPEG/JFIF (ff d8 ff), 모든 디코더로 추출드문 애니메이션 프레임
    0x61
    NotEnvelope
    0a
    ChildOverflow
    −18/−16
    쓰기 오프셋도 함께 이동해야 합니다
    0a
  • 다중 변형 컴플리케이션 슬롯: 활성 측정 항목은 .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에서 확인)은 펌웨어가 기본 보기에서 그리지 않는 슬롯입니다 — 그 값은 캔버스 가장자리 전에 들어갈 수도 없습니다. 미리보기에서 숨김 처리합니다.
  • aod
    0x22
    @69,209
    격리된 Normal|AOD 편집 UI
  • 독립형 0x60 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–36SpO₂77–81연락처
    37–42스트레스82–86다이얼/전화
    43–48수면87–91스포츠 (sportId ≠ 0)
    92음악93/94/96활동 기록 / PAI / 사이클