
Protocolo BLE com engenharia reversa para o CMF Watch Pro 2, documentando layout GATT, quadros de comando criptografados AES-128-CBC, handshake de autenticação e sincronização de dados de saúde para desenvolvimento de aplicativo companheiro alternativo.
Não oficial. Este documento descreve o protocolo Bluetooth Low Energy (BLE) do CMF Watch Pro 2 (CMF by Nothing), reconstruído através de engenharia reversa para um aplicativo complementar alternativo. Não é afiliado nem endossado pela Nothing/CMF. Use por sua conta e risco.
Todos os inteiros multibyte no cabeçalho do quadro e opcodes são big-endian. Inteiros dentro
de payloads de comando são little-endian, a menos que indicado o contrário (isso reflete o firmware do dispositivo) —
cuidado com as exceções (GOALS_SET, GPS_PUSH, offset/length de transferência em massa são big-endian).
Cada afirmação não óbvia abaixo é marcada com como foi estabelecida:
Dispositivo de teste para todas as capturas: CMF Watch Pro 2-5485, fw 1.0.0.73, serial CI04102520008192,
MCU Actions ATS3089C (Cortex-M4), tela 466×360.
O telefone é o cliente GATT; o relógio é o periférico, anunciando como CMF Watch Pro 2-XXXX
(4 caracteres hex).
Habilite notificações escrevendo 01 00 em cada CCCD (00002902-…). O canal de comando
(fff1/fff2) transporta o protocolo enquadrado abaixo. O canal shell (77d4…) transporta
texto simples no estilo AT (ex.: AT GETSECRET; veja §14). O canal de dados (02f0…) transporta
grandes blocos binários (mostrador, firmware, AGPS), coordenados por opcodes de controle no canal de comando.
✅ Uma sessão real inteira ocorreu no único canal de comando — durante uma captura de 160 s de uso intenso houve nenhum tráfego nos canais de dados/firmware ou shell, exceto durante uma transferência explícita de OTA/mostrador.
0xF5)Toda mensagem do canal de comando é encapsulada em um ou mais quadros com cabeçalho de 11 bytes:``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 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` juntos formam o **opcode** (veja §6). 🔎 confirmado com o construtor de quadros do aplicativo oficial (`C6117b.m30831g`).
- `chunkCount` = total de chunks para este comando; `chunkIndex` é **baseado em 1**.
- `chunkLen` = número de bytes de `chunk` neste quadro.
- Uma única escrita BLE pode ser fragmentada pelo MTU do link; o receptor armazena bytes brutos e reextrai quadros completos. Payloads grandes são divididos em múltiplos chunks (mesmo `cmd1/cmd2`, aumentando `chunkIndex`) e remontados em ordem.
### Convenção de opcode (✅ confirmado na transmissão)
- `cmd1 = 0xFFFF`: `cmd2` em `0x80xx`/`0x90xx` = telefone→relógio (requisição/configuração); `0x00xx`/`0xa0xx` =
relógio→telefone (resposta). Pares combinam pelo byte baixo (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- `cmd1` específico de funcionalidade: sufixo `cmd2` = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.
### Corpo do chunk
Para cada chunk, o corpo é `payloadPiece ‖ CRC32_LE(payloadPiece)` (CRC de 4 bytes, little-endian,
zlib/IEEE). Se o comando for **criptografado** (veja §3), todo o `payloadPiece ‖ CRC` é então
criptografado com AES-128-CBC/PKCS7 e esse texto cifrado se torna o `chunk` do quadro.
**Excentricidade do texto simples:** para opcodes em texto simples, o relógio *considera* o CRC de 4 bytes em `chunkLen`, mas **não** o transmite. Então, ao decodificar um quadro de texto simples, o comprimento real dos dados é `chunkLen − 4`.
(Quadros criptografados carregam o CRC dentro do texto cifrado como normal.)
Dimensionamento de chunks (para que chunks criptografados fiquem nos limites de bloco AES), com `maxWrite = mtu − 3`:
- criptografado: `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- texto simples: `maxWrite − 11 − 4 − 1`
✅ Todos os valores de `chunkLen` de quadros criptografados observados eram múltiplos de 16 (alinhamento de bloco válido).
---
## 3. Primitivas criptográficas
- **AES-128-CBC** com preenchimento **PKCS7** e um **IV fixo** (do firmware
`CmfCharacteristic.AES_IV`):
`50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`.
- **CRC32** (zlib/IEEE), emitido como 4 bytes little-endian.
- **SHA-256** sobre a concatenação das partes.
Derivação de chave:```
authkey = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16] // persisted across sessions
sessionKey = SHA256( nonce ‖ authkey )[0..16] // per connection
secret = segredo do dispositivo de 16 bytes (obtido do relógio através do comando shell
AT GETSECRET → GETSECRET:<32-hex>,OK).rnd1 = 16 bytes aleatórios escolhidos pelo telefone; rnd2 = 16 bytes aleatórios do relógio.nonce = bytes da resposta nonce do relógio.Após a chave ser definida, todos os quadros do canal de comando são criptografados com AES exceto os opcodes de texto simples listados em §5.
✅ Ambas as derivações validadas: authkey recuperado de um telefone com root ntwatch.db correspondeu ao valor derivado de um rnd1/rnd2/secret capturado; sessionKey reproduzido de um nonce capturado descriptografa quadros ao vivo.
Dois caminhos de entrada compartilham a mesma cauda nonce/confirm.
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
Em `AUTH_FAILED (0xFFFF,0xA061)` ou incompatibilidade de assinatura, a autenticação falha.
### 4.2 Reconectar (authkey já conhecido)```
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
✅ A ordem de reconexão (sem tráfego de shell) foi observada intacta em uma captura real.
⚠️→✅
TIMEé obrigatório antes de consultas de dados. ApósInitialized, o relógio não responderá aBATTERY,SERIAL_NUMBER_GETou ao handshakeACTIVITY_FETCH_*até que umTIME (FFFF 8004)tenha sido enviado na sessão — sem ele, apenas umFIRMWARE_VERSION_RETnão solicitado chega e tudo o mais expira. ✅ confirmado ao vivo (Pixel 8a): enviar os três GETs semTIME→ apenas firmware responde; enviarTIMEprimeiro → bateria e serial começam a responder.
Ordem recomendada para a fase 2: TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET →
BATTERY (0xA5) → envios de configuração → sincronização de saúde (§8).
Não há um opcode separado de "leitura" para a maioria das configurações. Enviar um *_GET (cmd2 = 0x0002, payload
0xA5) faz o relógio responder com o opcode SET (cmd2 = 0x0001) carregando o valor atual.
Comandos SET são confirmados com cmd2 = 0x0003 e corpo vazio.
Os frames são criptografados com AES assim que uma chave é definida, exceto estes opcodes, que sempre estão em texto plano:
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)Os cabeçalhos dos frames (cmd1/cmd2) sempre viajam em claro, então a sequência de comandos é visível em
qualquer captura mesmo sem a chave — apenas os payloads criptografados precisam de sessionKey.
(cmd1, cmd2)GET/SET/REQUEST = telefone→relógio; RET/REPLY/ACK/RESPONSE/DATA = relógio→telefone.
| Nome | cmd1,cmd2 |
|---|---|
| MUSIC_INFO_SET / _ACK | FFFF 905C / FFFF A05C |
| MUSIC_BUTTON | FFFF A05D |
| Nome | cmd1,cmd2 |
|---|---|
| WEATHER_SET_1 (o que funciona) | FFFF 906B |
| WEATHER_SET_2 (ignorado no Pro 2 — veja §9) | 0066 0001 |
Opcodes apenas JS (
FFFF 8051,FFFF 0051,FFFF 90A2,FFFF 90C5,FFFF A056,FFFF 908A/908Bstatus/suporte ChatGPT) são tratados no bytecode Hermes do aplicativo, não na camada Java. Seus cabeçalhos aparecem em capturas, mas a semântica do payload é ⚠️ [incerta].
Mostrador de relógio / firmware / AGPS usam um loop de init → solicitação-chunk/escrita-chunk → finish-ack:
(todos cmd1 = FFFF.) O relógio conduz o loop emitindo DATA_CHUNK_REQUEST_*(offset, length)
(offset/length = u32 big-endian); o telefone responde com DATA_CHUNK_WRITE_* carregando
payload[offset..offset+length] na característica de dados. Veja §11–§12 para detalhes.
Payload TIME (FFFF 8004) = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). Enviado logo após
a autenticação para que o relógio mostre a hora local (e desbloqueie consultas de dados — veja §4.3).
⚠️ Os timestamps de saúde do relógio são UTC. O aplicativo complementar deve adicionar o offset UTC local antes de derivar o dia calendário local / hora do dia. (Agrupar saúde pelo dia UTC bruto faz o dia mudar na hora local errada.)
TIME_FORMAT (005F 0001) payload = 1 byte: 00 = 24h, 01 = 12h.
ACTIVITY_FETCH_1; o relógio responde ACTIVITY_FETCH_ACK_1 (primeiro byte 01 ⇒ pronto).ACTIVITY_FETCH_2; então o relógio envia uma rajada de frames de dados:
ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].A sincronização é sequencial (deve seguir TIME; o relógio libera os streams após ACK_2), não uma
rajada única. Uma sessão pesada envia ~170–210 frames de notificação em ~160 s. ✅
ACTIVITY_DATA (32 bytes cada, LE) ✅Unidade de caloria: as calorias de atividade são relatadas em cal (calorias-grama). Divida a soma diária por 1000 para obter kcal. (Calorias de resumo de treino, por outro lado, já estão em kcal.)
timestamp(i32 LE) ‖ value(i32 LE)
(valor = bpm / % SpO₂ / índice de estresse).00DA 0001) é diferente — 5 bytes: timestamp(i32 LE) ‖ hr(u8).
✅ exemplo ao vivo 5e dc 29 6a 4e → ts, hr = 78 bpm. Faixa de pontuação de estresse: 1–29 / 30–59 / 60–79 / 80–99.SLEEP_DATA (cabeçalho de 18 bytes + N × 8 bytes de registros) ✅Um SLEEP_DATA = uma sessão de sono; uma noite pode conter várias (micro-despertares dividem as sessões).
Cabeçalho:
Cada registro de 8 bytes: timestamp(u32) ‖ duration_s(u16) ‖ stage(u16).
Códigos de estágio: 1 = Profundo, 2 = Principal/leve, 3 = REM, 4 = Acordado. ✅ validado contra uma noite completa
(duas sessões, totais D/C/R/A reconciliam).
WORKOUT_SUMMARY v1 (54 bytes) / _V3 (0160 0001)v1: start(u32), end(u32), duration_s(u32), depois tipo/calorias/passos/distância/FC-média e um
bloco GPS/extendido. ✅ layout v1 confirmado contra firmware. WORKOUT_SUMMARY_V3 é um layout mais novo
para os mesmos dados mais um bloco estendido de ~40 bytes (exerciseLoad, aeróbico/anaeróbico, recoveryTime,
VO₂max, cadência, PAI, melhores tempos de corrida…). O conjunto de campos é conhecido (do banco de dados Room do aplicativo), mas os
offsets exatos dos bytes dentro desse bloco de 40 bytes são ⚠️ [incertos] — fechá-los precisa de uma captura bruta
de um treino com GPS.
Strings são UTF-8, truncadas em bytes até o tamanho do campo (o truncamento pode dividir um caractere multi-byte,
igual ao comportamento s.encode()[:max] do firmware); campos curtos são preenchidos com zeros à direita.
0065 0001) ✅: iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ title ‖ body.
iconCode seleciona o ícone do aplicativo (WhatsApp=8, Telegram=12, Instagram=18, Gmail=27; desconhecido=0xFF).
Title ≤ 20 bytes, body ≤ 128 bytes. Enviado de um cliente → relógio exibiu + ACK 0065 0003.005C 0001) ✅: resposta = level(1) ‖ charging(1) (ex.: 3b 00 = 59 %, não carregando).00DE 0001) ✅: len(1) ‖ ASCII (ex.: 10 + "CI04102520008192").0095 0001) ✅: height_cm(1) ‖ weight_kg(1) ‖ age(1) ‖ gender(1: 1=M)
(ex.: = 172 cm / 73 kg / 31 / masculino).now/utc_offset como parâmetros explícitos
(determinístico, testável). O transporte fornece a hora real.TIME é a chave para tudo (§4.3) — envie-o primeiro ou o relógio fica mudo em consultas de dados.GOALS_SET e
GPS_PUSH são big-endian, e offset/length de transferência em massa são big-endian.O relógio suporta (a) mostradores de foto/personalizados (uma imagem de fundo + um relógio digital desenhado pelo firmware) e (b) mostradores estruturados (mostradores internos / da loja: um fundo mais camadas de sprites posicionados, ponteiros e widgets de texto). Ambos transferem pelo canal de dados via loop init → chunk em §6.
O que realmente funciona (✅ validado ao vivo): construir um mostrador de foto a partir de qualquer imagem e instalá-lo; instalar qualquer um dos 103 mostradores da loja offline; revestir um mostrador estruturado (trocar o fundo ou qualquer sprite não-fundo) e mover suas camadas; reordenar / alternar o mostrador ativo; e construir um mostrador estruturado do zero — o envelope de cena
0x20é decodificado e o construtor está implementado (§11.7), comprovado offline para fazer round-trip de todos os 103 mostradores da loja byte por byte e para emitir contêineres sintéticos que passam o próprio validador do firmware. 🟡 o único passo não comprovado é ver um render sintético do zero no dispositivo via9075(a prova offline estrutural já cobre o que costumava causar a rejeição0a). Não há barreira de codec ou transporte e nenhuma necessidade da toolchain do fornecedor. As antigas alegações de "render estruturado é assado em RES-pack / impossível via BLE" e "codec do lado do servidor cf=0x1f" estavam erradas (um bug de offset+bytes-per-pixel) — o firmware renderiza mostradores estruturados orientados a dados a partir do arquivo que você envia.
DIAL_COMMAND (9055 / a055) ✅a055 = result(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. Exemplo: 01 05 06 07 … = ativo #5, 6 mostradores, máximo 7.CHANGE_DIAL (009F 0001) é inerte no fw 1.0.0.73 (retorna uma constante, não troca) — não o use.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)
Byte de resposta final: `01` = ativado e salvo; `0a` = armazenado mas **não** ativado/rejeitado. No
Android, cada `DATA_CHUNK_WRITE` deve ser enviado como **um BLE write por quadro** — concatenar e
re-cortar por MTU dessincroniza os cabeçalhos e os loops do relógio solicitando offset 0.
- **`9063` (foto) = APPEND.** A lista de mostradores cresce (6→7); `watchfaceId = 0xFFFFFFFF` (sentinel
personalizado) para que nunca seja rejeitado como duplicata, e o relógio o ativa automaticamente.
- **`9075` (estruturado) = REPLACE** o slot do `old_id`. O `old_id` **deve** já estar na lista
(caso contrário `0a`). Para reinstalar um id já presente, **delete-o primeiro** (9055 lista-menos-id)
então carregue "novo" — reutilizar um id no lugar dá `0a`.
### 11.3 Mostrador personalizado / dial de foto — ✅ totalmente validado ponta a ponta
**Container** (ida e volta verificado por byte; todos os campos em little-endian):```
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 = bloco LZ4 padrão sobre RGB565 little-endian, top-down (payloadLen conta a partir do
primeiro byte LZ4). O aplicativo oficial usa LZ4-HC e remove o cabeçalho/rodapé de 21 bytes do bloco LZ4; um
codificador LZ4 simples apenas com literais também funciona — o relógio aceita qualquer LZ4 válido, a identidade de bytes não é
necessária. Pixels fora do círculo inscrito (centro 233,233, raio 233) são definidos como 0x0000.
INIT_2 para 9063 — cabeçalho exato (✅ este é o que funciona):```
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` = comprimento exato do `.bin`; `FFFFFFFF` = `watchfaceId` personalizado; `styleId` 0–4 seleciona o layout do relógio digital embutido (é sempre desenhado — não há "desligado"); `posX/posY` posicionam-no (56 / 77 conhecidos como bons); `color565` o tinge (ex.: `FFFF` = branco). ⚠️ A forma mais curta `A5 ‖ size ‖ watchfaceId` é **rejeitada** com finalização `0a` — use o cabeçalho completo acima. (Implementação de referência: `core-rust/engine.rs::build_wf_init2`, espelhando `C6135t.m31104u` no aplicativo oficial.)
**Receita:** redimensionar a imagem para 466×466 (e uma miniatura de 270×270), converter para RGB565-LE de cima para baixo, opcionalmente zerar os pixels fora do círculo, comprimir cada um com LZ4, montar o contêiner acima e enviar via o pipeline `9063` com `watchfaceId = 0xFFFFFFFF`. (Codec de referência: `core-rust/watchface.rs`, `work/codec_dfa.py`.)
### 11.4 Mostrador estruturado / de loja — contêiner e codecs ✅
**Cabeçalho** (idêntico em todos os 103 mostradores de loja; todos os campos little-endian):```
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
Não há checksum de bloqueio (CRC32/Adler32/byte-sum falham todos) — o reempacotamento não é bloqueado. Mostradores fictícios (~173 B, ex. ids 273/274/277) são placeholders para faces gravadas na ROM: cabeçalho + diretório, sem assets reais.
Assets — cada um é dimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload), onde
cf = dimsWord & 0x1f, w = (dimsWord >> 10) & 0x7FF, h = (dimsWord >> 21) & 0x7FF, e len
conta a partir do primeiro byte LZ4 (o 1f 00 01 00 que você vê frequentemente é o primeiro token LZ4 — não
pule). Tamanho descomprimido = w·h·bpp:
✅ Todos os 4151/4151 assets nos 103 mostradores descodificam exatamente com um descompressor lz4.block
padrão em w·h·bpp. Transparência é o byte alpha (cf=5/24) ou 0x0000 (cf=4 fora do
círculo) — não há RLE nem "escape". Codificação = re-raster → LZ4 padrão → [dimsWord][len][LZ4].
INIT_2 para 9075 — corpo encriptado com AES:```
kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)
`kind` = `0x02`/`0x03`; `old_id` = mostrador ativo atual (de `9055`); `file_len` = tamanho real do `.bin` (= `@0x18 + 36`). Instalar um `.bin` da loja como está é o caminho garantido (Ring Data id 359 + 102 outros confirmados). (Referência: `core-rust/engine.rs::build_dial_replace_init`.)
### 11.5 Gramática de diretório estruturada ✅ (decodificada e implementada — REVISADO 2026-07-02)
> **⚠️ Revisão (2026-07-02): o esquema de registro plano `61 01 00` abaixo estava sistematicamente OFF-BY-ONE.** O corpo da cena é um TLV limpo (§11.7); um **corpo de folha** desenhável (tags `0x30`/`0x38` estáticas, ponteiro `0x70`) é:
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - attr `0x01` abre o corpo: **X,Y = canto superior esquerdo** na tela 466² (o `s16 x,y` do `sty_picture_t` do SDK).
> - a **tabela de quadros `61 …` fecha o corpo** (`base` = ponteiro de asset; `count` 1 = imagem, 10/11 = atlas de dígitos — o antigo "tipo de registro `0a/0b`" era na verdade esta contagem! — 7/13/2 = folha de quadros de complicação).
> - extras do ponteiro: origem+escala `[src] 00 3c 00` dentro do attr `0x01`; pivô no **trailer** `05 05 00 01 [pivX][pivY]`. **Centro de rotação = `(X+pivX, Y+pivY)` por ponteiro** — não fixo (233,233): existem submostradores descentralizados (por exemplo, os ponteiros do mostrador 366 giram em torno de 150,150).
>
> A varredura linear por `61 01 00` estava costurando a tabela de quadros+pivô do elemento **N** ao X/Y (e byte de tag, o antigo "f3") do elemento **N+1** — só parecia certo em mostradores analógicos cujos ponteiros adjacentes compartilham geometria quase idêntica. A "parede de variante compacta" (especificação 24 §24.4.5) era esta mesma leitura incorreta. Implementado como `scan_scene_drawables` em `core-rust/watchface_struct.rs` e `wfweb/src/codec/parse.ts` (cena = fonte primária para imagens/ponteiros; varredura plana mantida para texto + fallback não envelope). Validado pelo oráculo `wfweb/compare.html` (render vs PNGs oficiais da loja, 99 mostradores): 64→72 bons, 8→5 ruins, diferença média 9,3→7,4%.
Leitura histórica de registro plano (substituída, mantida para contexto):
- **Imagem estática** (`61 01 00`): `asset_ptr(u32) ‖ elemId(u16) ‖ 05 05 00 01 ‖ pivotX(u16) ‖ pivotY(u16) ‖ 3B ‖ 01 ‖ 1b 00 ‖ X(u16) ‖ Y(u16)`. Canto superior esquerdo na tela 466² = `(X−pivotX, Y−pivotY)`.
- **Ponteiro/mão** — mesmo registro de imagem, rotacionado em tempo de execução. **Centro de rotação = `(X+pivotX, Y+pivotY)`** (≈ 233,233 em mostradores analógicos). A **fonte de dados é um `u8` no offset do registro `+36`**, escala `u16` em `+38` (=60): `0x0a`/`0x70` = hora (`h·30°+m·0.5°`), `0x0e`/`0x71` = minuto (`m·6°+s·0.1°`), `0x12`/`0x72` = segundo (`s·6°`). ✅ confirmado pela desmontagem dos getters (fallback RTC 10:10:30).
- **Widget de texto/número** (`61 0a 00`): `asset_ptr(u32) ‖ [10×u16 font metrics] ‖ 40 01 00 ‖ flag ‖ 3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`. `asset_ptr` aponta para o glifo "0"; dígito *d* = o asset em `index("0") + d` (10 sprites consecutivos cf=5, por exemplo, `0123456789` e pontuação `,°`). ✅ renderizado.
- **Preenchimento de complicação = índice de quadro** (✅ confirmado para complicações de dígito/enum/medidor, contagem>1): o valor indexa uma **folha de quadros pré-renderizada** no `.bin` — `quadro = (contagem−1)·val/100` (percentual) ou `quadro = valor` (dígito de alternância/enum). Tabela de quadros = sub-registro `61 ‖ contagem(u16) ‖ base(u32) ‖ contagem×id(u16)`. Por exemplo, o grande dígito do 327 Digit Max é uma folha de 13 quadros (números 0–12), `quadro = hora`.
- **Anel de progresso / arco = clipe de setor em tempo de execução** (✅ 2026-07-02, **corrige a leitura "anéis são folhas de quadros" na especificação 25 §2**): o elemento tag **`0x81`** carrega um **único** disco completo (`61` tabela de quadros `contagem == 1`), e a cunha parcial é aquele disco **recortado em um setor de pizza** (`frac = valor/máximo`, sentido horário a partir das 12 horas) — verificado pixel a pixel no 322 Glare 2 e confirmado `contagem==1` em **20 mostradores**. No disco: corpo `0x81` = sub `0x01` (geometria `x@+0 y@+2 w@+4 h@+6`, inline `61 1 base` = disco) + sub `0x5b` (`máx` u16 `@+4`, =100 exceto 332=60). Implementado no wfweb (`blendSector`).
- **Id da fonte de dados** — o bloco de atributos `82` do elemento está em `delim+3` (após o último `40 01 00`), e o **id da fonte é um `u8` em `+0x14`** (também `relX@+0x07 s16`, `relY@+0x09 s16`, `ancora@+0x0C/0E`, `modo@+0x15`, `contagem-de-quadros@+0x1A`). Âncora < 0 = alinhar à borda do pai. 🔎 O firmware resolve o id através de uma tabela de getters de 142 entradas em `0x101f371c` (cada uma chama `ux2sys_get(tipo)`). Ids comuns (§16): `0x07` hora, `0x0b` minuto, `0x0f` segundo, `0x16` mês, `0x18` dia da semana, `0x13` AM/PM, `0x19` HR, `0x1b` bateria %, `0x24` temperatura, `0x36` passos, `0x70/71/72` ângulos das mãos, `0x25–27` meta %. (Isso corresponde ao grupo de exemplo `0x07:0x0b:0x0f` = HH:MM:SS abaixo.)
- **Nó de grupo** (`0x68`): aninha seus filhos dentro de seu próprio corpo TLV (`0x60` = valor/texto, `0x30` = estático); cada `0x60` carrega seu id de fonte em `data+16`. Por exemplo, um grupo `0x07:0x0b:0x0f` = relógio HH:MM:SS. Analisador de elemento TLV = `0x100db55c` (tabela de salto indexada por `tag−0x70`).
### 11.6 Matriz de autoria
| Caminho | Status | Notas |
|---|---|---|
| Mostrador de foto a partir de qualquer imagem | ✅ **concluído** | §11.3; validado no dispositivo |
| Instalar qualquer um dos 103 mostradores da loja | ✅ **concluído** | §11.4; `9075`, `old_id`=ativo |
| Recolorir fundo cf=4 de um mostrador da loja | ✅ **funciona ao vivo** | trocar o payload COMPLETO no lugar, definir o `len` do asset para o **novo** tamanho de bloco (≤ antigo), manter a mesma pegada de arquivo, instalação limpa |
| Re-autoria por template (trocar pixels de qualquer camada + mover geometria) | ✅ **renderiza via BLE** | mostrador 373: fundo→ciano + um sprite cf=5→vermelho + movimento X 224→100, tudo renderizado, mãos ao vivo |
| Mostrador estruturado 100% sintético do zero | ✅ **construtor concluído, validado offline** | construtor de envelope `0x20` em `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`); ida e volta em todos os 103 mostradores byte-exato + sintético passa validador do firmware (§11.7). 🟡 renderização no dispositivo sobre `9075` ainda não filmada |
| Fontes do sistema (`.font`) | ✅ **decodificar/renderizar (todas)** | LVGL bin (não proprietário); 32 fontes numéricas (`num*/nm*`, descomprimidas) + 24 fontes de texto (`font*`, LVGL RLE `comp=1`) todas decodificadas — 12208 glifos, 0 estouros, ASCII completo. RLE = LVGL v8.3 `lv_font_fmt_txt.c` (3 estados SINGLE/REPEATE/COUNTER + pré-filtro XOR por linha), portado 1:1, sem desmontagem |
⚠️ Armadilhas de recolorir/re-autoria que causam tela preta ou `0a`: deixar o **`len` do asset antigo** (o relógio lê além do bloco → estouro → preto); **aumentar o arquivo** (rejeitado na instalação); reutilizar um id **no lugar** em vez de uma instalação limpa.
### 11.7 O envelope de cena `0x20` — decodificado e construtor implementado ✅
Um mostrador **real** re-escrito renderiza porque preserva o envelope de cena do arquivo. Um corpo puramente sintético de registros planos `61 …` é **rejeitado** — o analisador do firmware (`WFManager_Parser`, `0xdb35c`) exige que o corpo (a partir do offset `0x24`) comece com um container de cena `0x20`. O arquivo completo é:```
[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
A cena é uma TLV aninhada limpa — [tag u8][len u16 LE][body], tags de contêiner 0x20/0x21/0x22/0x68
recursivas, folhas desenháveis 0x30 (estático) / 0x70 (elemento/ponteiro) / 0x80 / 0x81 / 0x86 (nome).
(Os registros planos 61 01 00 / 61 0a 00 são padrões que vivem dentro dos corpos desenháveis; o
antigo analisador os encontrava heuristicamente — e costurava corpos adjacentes, veja a revisão §11.5.
O layout do corpo desenhável agora está totalmente decodificado ali.) O offset+len de cada filho deve caber dentro da janela
de seu pai;
primeiro byte do corpo ≠ 0x20 → erro do analisador −16; um filho ultrapassando sua janela → −2; qualquer um faz o
manipulador 9065 (0xeb50c) escrever o final .
O Builder está implementado e validado offline (core-rust/watchface_struct.rs:
SceneNode / serialize / parse_scene / validate_container / build_container /
build_container_raw; CLI cmfwatch-wfgen reframe):
scene_roundtrip_identity — todos os 103 mostradores da loja: parse_scene→serialize reproduz a cena
byte a byte (os lens aninhados recalculados correspondem) e validate_container passa em todos.build_reframe_identity / CLI reframe — remontar o .bin inteiro do zero reproduz
o arquivo byte a byte exceto por 1 byte de preenchimento de nome (@0x17; não é checksum).build_container_synthetic — compõe um novo mostrador (fundo + desenhável aninhado em 20→21) que
passa pela invariante exata do firmware (build_container emite janelas aninhadas corretas).validate_rejects_bad_containers — rejeita um corpo plano (→ , o bug histórico )
e um filho que ultrapassa sua janela (→ ).🟡 Ainda não comprovado (precisa do relógio, não bloqueante): enviar um sintético criado do zero via 9075
e vê-lo renderizar — a prova estrutural offline já cobre o que causou a rejeição 0a.
Cruzou-se a renderização do wfweb com as miniaturas oficiais da loja (oráculo de pixels em todos os 103 mostradores) e fecharam-se quatro lacunas:
i16 (com sinal). ✅ Âncoras podem ser negativas para elementos que
se estendem para fora da tela — por exemplo, o ponteiro vermelho dos segundos do 275 está em Y = 0xFFFC = −4 (um sprite de 30×281,
fonte 0x12, rotacionado do centro para fora da borda superior). Ler X/Y como u16 (65532) fazia o
guardião descartá-lo. Analise ambos como com sinal e permita uma pequena faixa negativa.0x60 img_numbers de nível superior (não apenas dentro de um grupo 0x68), e a
fonte de dados real é o u8 no offset −5 do registro — a varredura direta do atributo 82 está
sistematicamente com deslocamento de um aqui e captura o atributo do próximo irmão (no 275 o dígito dos minutos
pegou o dia da semana 0x18). O "10:10" do 275 = hora 0x07@X≈306 + min 0x0b@X≈369 com o
como um estático adjacente entre eles, cada um um atlas de 11 glifos (). ⚠️ Ao corrigir X/Y
de , os , ou uma reexportação corrompe esses bytes (quebra
a mesma pegada → ).Também: as miniaturas oficiais da loja são renderizadas às 10:10 (horário clássico de marketing), não às 10:12 — combinar o horário do oráculo para 10:10 reduz a diferença média de pixels visivelmente. O analisador do wfweb agora faz ida e volta em todos os 103 mostradores exatos em bytes (a correção do offset de escrita X/Y acima eliminou as últimas incompatibilidades).
0x22 AOD em sua própria visão. ✅ O caminhador de cena já pula 0x22,
mas a varredura plana de texto/número percorria todo o [0x30, firstAsset) — então emitia a
variante sempre ativa (AOD) de cada elemento como uma camada normal. No "Gradient" o atlas de data cinza da AOD
(offset em 0x22) desenhava sobre o vermelho normal. Correção: marque cada registro 0x22 com
layer.aod=true (com seu próprio conjunto de dedup) e deixe renderAt(…, aod) mostrá-los apenas no modo AOD
(o modo normal oculta as camadas aod; o modo AOD oculta as normais; o fundo é trocado por setAod
e sempre desenha). Ganho líquido do oráculo no modo normal em todo o corpus (284: 31%→21%, +18 outros) —
as variantes AOD estavam sobrepondo muitos mostradores — e a alternância AOD do editor agora mostra o layout
real sempre ativo em vez dos normais. A AOD real é uma tela preta (sem cena escurecida):
se o mostrador não possui um quadro de fundo AOD dedicado (dial.aod), a cena normal é ocultada no modo AOD
para que renderize preto + os elementos em suas próprias cores. Os AOD são analisados via
o caminhador de cena também (agora ele percorre o contêiner marcando os desenháveis como , em vez de deixá-los
para a varredura plana onde seu pivô não correspondia → "não posicionados"); os ponteiros AOD giram no centro
da tela (o às vezes carrega um x/y de ponteiro fora do centro que o firmware ignora — por exemplo,
a hora do Gradient ). O editor também expõe isso como uma
(§UI): cada tela mostra apenas suas próprias camadas e as edições persistem independentemente. A renderização em modo normal é
idêntica byte a byte em todo o percurso; a ida e volta permanece exata em bytes em todos os 103 mostradores.40 01 00 XX (✅ confirmado pelo firmware)Quantos dígitos um img_number desenha é um único byte no registro de campo — o byte de dados XX
do sub-registro de atributo 40 01 00 XX do elemento (o sub-registro 0x40 que fica após a
tabela de quadros 61 [count][base][glyph-ids]):
XX & 0x0F = número de slots de dígito (0 ⇒ padrão do firmware 7).0x80 = zero-pad (mostrar zeros à esquerda, por exemplo, "09" vs "9").Confirmado por desmontagem do firmware (imagem XIP 0x10000000; rotina de renderização 0x100d8e60):
NDIG = ldrb[40sub+3] & 0x0F (→7 se 0); o valor é limitado value % 10^NDIG e exatamente NDIG
glifos são desenhados MS primeiro, zeros à esquerda suprimidos a menos que bit7. O u16 após a fonte (60 para
data, 1000 para kcal) NÃO é a contagem — ele alimenta apenas a inserção do glifo separador de milhares/milhões
(cmp #1000/#1000000), é por isso que editá-lo não fazia nada. O id da fonte também não limita.
Histograma do corpus sobre todos os 620 campos numéricos corresponde: campos de 2 dígitos (hora/min/seg/data/temp/FC) terminam
40 01 00 02/0x82; kcal …04; passos …05; divisões de relógio de dígito único 0x81. Portanto, o campo de data
40 01 00 82 = 2 dígitos, preenchido com zero — essa é a razão pela qual uma temperatura Fahrenheit reatribuída (≥100) foi truncada.
Correção / editor: wfweb analisa digitCount/digitZeroPad (+digitCountOff) para campos numéricos,
expõe "Dígitos" + "Preenchimento com zero" no inspetor, escreve o byte no local (mesma pegada),
e a prévia limita/preenche para digitCount para espelhar o firmware. Portanto, reatribuir a fonte de um campo e
definir sua contagem de dígitos funciona para qualquer campo (por exemplo, data→temperatura °F → Dígitos 3). Oráculo de modo normal
inalterado (0 regressões, 3 pequenas melhorias); ida e volta exata em bytes em todos os 103 mostradores.
(A hipótese anterior de "largura de dígitos"/rectW estava errada — largura é apenas layout, não a contagem.)
A tabela de transferência está na §6. Pontos adicionais confirmados:
000000010000…. O laço completo
init → [A05F ↔ 905F]×N → finish foi observado no fio (~892 chunks).9040–9042, finish 9041) 🔎: estrutura mapeada; payload INIT2 = bytes de versão
(por exemplo, 0b 00 00 39 = 11.0.0.57). Não testado em campo (o aplicativo desabilita a atualização de FW aqui). As imagens de firmware
parecem ser não assinadas — a integridade é apenas CRC32 (nenhuma chave assimétrica observada em RE).FACTORY_RESET (009A 0001) compartilham a sessão autenticada, uma única autenticação BLE
válida é suficiente para limpar ou (em princípio) brickar o relógio. Manuseie com cuidado.✅ Hardware exposto via BLE:
WORKOUT_GPS) e envio de localização (GPS_PUSH).Não há barômetro/altímetro, bússola, giroscópio ou sensor de temperatura da pele/corpo. Um termistor NTC
interno (temperatura da placa/bateria) existe, mas é legível apenas via canal AT
(AT GETNTCTEMP, §14) — o fluxo de histórico de temperatura da pele 0155 está vazio neste SKU.
Sequestros de widget de dados (não há API real de complicação/vinculação de dados — veja §11.5): os campos de texto
existentes do relógio podem ser reaproveitados para mostrar dados externos de relance. Comprovado ✅: a string de cidade
do clima (WEATHER_SET_1, por exemplo, "BRA 2x1 ARG" apareceu no widget) e os campos de música
artista/faixa; a lista de contatos (20 × nome[32]+número[25]) funciona como um painel de dados rolável.
Todos são envios, não complicações persistentes.
77d4ff01 / 77d4ff02)Um canal de comandos AT em texto simples separado, independente do protocolo emoldurado. ✅ testado ao vivo:
AT GETSECRET (segredo de pareamento de 16 bytes), GETVERSION, GETSN, GETNAME, GETPID,
GETBATLV (mV bruto, por exemplo, 3853mv), GETGSENSOR (aceleração bruta em g, X=… Y=… Z=…),
GETNTCTEMP (°C, NTC interno).AT SETMOTOR=1 (vibrar o motor), SETHR/SETHRV/SETSPO2=… (injeção de teste
de sensor), SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH.As respostas terminam em ,OK. Comandos SET* geralmente executam, mas podem não ecoar ,OK via BLE — confirme
caso a caso.
Alguns recursos estão presentes no firmware, mas desabilitados por SKU/região e não são acessíveis pelo telefone/BLE — eles precisam de uma modificação do firmware, o que está fora do escopo aqui:
0x9e do ux2sys, inicializado a partir de NVRAM/EFUSE/região na inicialização; neste
SKU o sinalizador de suporte 908b = 00. Não influenciável por telefone, conta ou BLE (confirmado por
experimento + RE). O aplicativo é apenas um relé; o áudio vai telefone → servidores da Nothing.Não é necessário para construir um cliente BLE — incluído para completude. O renderizador de mostrador do firmware vincula cada
slot de complicação a um id de getter numérico (tabela de despacho de 142 entradas). Ids selecionados:
0x07 hora, 0x0a ângulo de relógio combinado, 0x0b minuto, 0x0f segundo, 0x18 dia da semana,
0x19 frequência cardíaca, 0x1b % da bateria, 0x24 temperatura, 0x36 passos,
0x70/0x71/0x72 ângulo do ponteiro de hora/minuto/segundo, 0x25–0x27 % de meta. Complicações de anel/arco indexam uma
folha de quadros pré-renderizada (por exemplo, 50% = quadro 50 de 100), não um arco por pixel — os quadros estão embutidos
no .bin que você envia (§11.5), portanto nenhum pacote RES externo é necessário.
QUICK_CARD (906D) ✅Os tiles da tela inicial do relógio. O telefone apenas escolhe quais tiles mostrar e em qual ordem — os tiles são
renderizados pelo firmware (nenhum canal de conteúdo). Primeiro byte do payload = subcomando: 00 = GET, 01 =
SET; ambos usam 0x906D (0x906C está listado, mas não usado — consultá-lo dá timeout). Resposta = A06D.
🛑 Enviar um
assemblyIdinventado limpa as telas do relógio (ele aceita a lista, não consegue corresponder aos ids, não mostra nada). Envie apenas ids que você leu de volta via GET; recupere via o aplicativo oficial ou um reset de fábrica.
Resposta GET ✅: status(1) ‖ 00 ‖ N(1) ‖ N × grupo, grupo = tag=01 ‖ K(1) ‖ K×(assemblyId, sportId).
Quadro real: 01 00 04 01 02 5d00 6100 01 03 1900 2e00 2300 01 03 5c00 0400 5a02 01 03 4800 5100 5300
= 4 telas / 11 cartões (5a02 = cartão Esporte, sportId 2).
Slots: cada tela tem 4 slots. O tipo de um cartão define seu tamanho — circular/square = 1 slot,
rectangle = 2 slots. A validação é aritmética pura de slots (Σ ≤ 4 por tela); nenhum cartão mutuamente exclusivo.
sportId é 0 exceto em cartões Esporte (87–91). Os ids 64 e 95 não existem.
Catálogo de assemblyId (cada tipo lógico = um intervalo contíguo de 6 variantes de estilo _0.._5;
0 = slot vazio):
Os layouts de bytes acima foram reconstruídos a partir do firmware (1.0.0.73), do APK oficial (3.5.7) e
de capturas ao vivo descriptografadas contra um dispositivo real. A implementação de referência para este projeto reside em
core-rust/src/{commands,frame,crypto,health,session}.rs (Rust) e nas ferramentas Python cmftool/
(pair.py, session.py, wf_codec.py, upload_custom.py, …).
| Propósito | Serviço | Característica | Propriedades |
|---|
| Escrita de comando | 0000fff0-0000-1000-8000-00805f9b34fb | 0000fff2-… | Write |
| Notificação de comando | 0000fff0-… | 0000fff1-… | Notify |
| Escrita de shell (AT) | — | 77d4ff01-2fe2-2334-0d35-9ccd078f529c | Write |
| Notificação de shell (AT) | — | 77d4ff02-… | Notify |
| Escrita de dados em massa | — | 02f00000-0000-0000-0000-00000000ffe1 | Write |
| Notificação de dados em massa | — | 02f00000-…ffe2 | Notify |
| Nome | 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 |
| Nome | 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 |
| Nome | 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 |
| Nome | 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 |
| Nome | 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 🔎 (FC/cal/passos ao vivo) | FFFF 9078 / FFFF A078 |
| FEMALE_CYCLE_SET / _RET | FFFF 9071 / FFFF A071 |
| SLEEP_CONFIG_SET / _RET (minutos alvo) | 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 |
| Nome | cmd1,cmd2 |
|---|
| DIAL_COMMAND_SET / _RET (listar/reordenar/selecionar) | FFFF 9055 / FFFF A055 |
| DIAL_CONFIG_SET / _RET | FFFF 9075 / FFFF A075 |
| CHANGE_DIAL (⚠️ inerte em 1.0.0.73 — não usar) | 009F 0001 |
| QUICK_CARD_SET/GET / _RET (ambos em 906D) | FFFF 906D / FFFF A06D |
| Nome | 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 🔎 (vazio neste SKU) | 0155 0001 / 0155 0002 |
| WORKOUT_SUMMARY / _V3 | 0057 0001 / 0160 0001 |
| WORKOUT_GPS | FFFF A05A |
| Domínio | INIT1 req/resposta | INIT2 req/resposta | CHUNK req/escrita | FINISH ack1/ack2 |
|---|
| Mostrador (foto) | 8052/0052 | 9063/A063 | A064/9064 | A065/9065 |
| Mostrador (estruturado/troca) | 8052/0052 | 9075/A075 | A064/9064 | A065/9065 |
| Firmware | 9052/A052 | 9040/A040 | A042/9042 | A041/9041 |
| AGPS/EPO | 905E/A05E | — | A05F/905F | A060/9060 |
| Offset | Tamanho | Campo |
|---|
| 0 | 4 | timestamp (segundos epoch) |
| 4 | 4 | passos |
| 8 | 4 | distância (m) |
| 12 | 4 | calorias |
| 16 | 16 | reservado (observado 0) |
| Offset | Tamanho | Campo |
|---|
| 0 | 4 | session_start (epoch, UTC) |
| 4 | 4 | wakeup (epoch, UTC) |
| 8 | 2 | total_deep_s |
| 10 | 2 | total_core_s |
| 12 | 2 | total_rem_s |
| 14 | 2 | total_awake_s |
| 16 | 2 | ⚠️ [incerto] (id/pontuação da sessão? valores observados não correspondem às somas dos registros) |
ac 49 1f 0100D5 0001) ✅: N × 57 bytes = name(32) ‖ phone(25). A interface do relógio mostra até 20.0063 0001) ✅ — corrige Gadgetbridge (que colocava o rótulo no final,
preenchido com 0xff — errado). 40 bytes por alarme, big-endian:
secondsOfDay(i32) ‖ index(u8) ‖ enabled(u8) ‖ repetition-bitmask(u8) ‖ flag(u8) ‖ label[32] UTF-8.
O rótulo está no offset 8 e aparece no relógio. repetition = máscara de bits de dias da semana (0 = uma vez);
flag é ⚠️ [incerto] (marcador de uma vez?). Exemplo (13:30, idx 2): 0000bdd8 02 01 15 00 "Alarm…".005E 0001) ✅ — o aplicativo oficial e a implementação de referência usam o
DailyTargetBean v1 big-endian de 10 bytes: steps(u32 BE) ‖ distance_m(u32 BE) ‖ calories_kcal(u16 BE). (Esta é a forma do Gadgetbridge; relatos anteriores de que o relógio "ignorava"
era um bug de descriptografia de sessão obsoleta, não um problema de payload.) 🔎 A engenharia reversa do firmware também mostra uma
variante estendida 29 bytes maior (adiciona sleep_min/exercise_min/stand_h + 6 flags de ativação, todos u32
BE após um prefixo flag(u16 LE), com intervalos aplicados: passos 2000–30000, dist 1000–99000, cal
100–5000, sono 360–720, exercício 30–90, em pé 6–16) — não é o caminho padrão do aplicativo; prefira a
forma de 10 bytes a menos que precise dos alvos extras.0060/0061 0001) ✅: 11 bytes:
enabled(1) ‖ threshold_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE). Note que a "janela ativa
08:00–22:00" mostrada na interface é um padrão fixo de firmware e não é transportada no payload.00DC 0001) ✅: count(1) = 36 slots ‖ activityTypeCode[36] (códigos ativos então preenchimento 00
). Seleciona quais esportes aparecem no menu de treino do relógio.009B 0001) ✅: byte kind — 01 = FC 24/7, 02 = SpO₂,
04 = estresse (medido a cada 30 min).FFFF 9059) ✅: desativado = 00; ativado =
01 ‖ hrLow ‖ hrHigh ‖ sportHrHigh ‖ spo2Low ‖ 00 00 00 00 (um limite 0/255 = "sem limite").FFFF 9071) ✅: 01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ period(1) ‖ cyclePeriod(1) ‖ cycleStartDate(u32) ‖ markStart(u32) ‖ markEnd(u32) (capturado: period=5, cyclePeriod=0x1c=28).FFFF 9073) ✅: TLV — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]…
(7 respostas padrão capturadas e descriptografadas).FFFF 906F) ✅: envia IDs de cidade numéricos, não nomes (01 ‖ count ‖ cityId(2 BE)…);
o relógio mapeia ids de uma tabela interna. Config DST FFFF 9083 =
count ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ start(u32 LE) ‖ end(u32 LE)]….FFFF 905C, 131 B) ✅: state(1: 0=nenhum/1=pausado/2=tocando) ‖ volume(1) ‖ volumeMax(1) ‖ track(64) ‖ artist(64). O relógio também envia MUSIC_BUTTON (A05D) de volta.FFFF 906B, 199 B) ✅ — use este: 7×9 bytes dias + 24×2 bytes horas +
cidade(32) + 7×8 bytes nascer/pôr do sol (LE). Temperaturas codificadas como (temp_c + 100) & 0xFF.
⚠️ O mesmo payload enviado em WEATHER_SET_2 (0066 0001) não atualiza o widget de clima no
Pro 2 — sempre use 906B. (A string da cidade também é um vetor comprovado de sequestro de dados — veja §13.)005D 0001) ✅: payload 0x01 → relógio toca/vibra (+ ACK 005D 0003).FFFF 906A) ✅ — big-endian, longitude primeiro: 16 bytes
ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. Validado para uma localização real.FFFF A05A) ✅ — little-endian, longitude primeiro: 12 bytes
ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).FFFF 8004): veja §7.| cf | bpp | raster (após LZ4) | uso |
|---|
| 4 | 2 | RGB565-LE | fundo opaco (FULL/THUMB) |
| 5 | 3 | RGB565-LE (2 B) + alpha (1 B) por px | sprites anti-serrilhados (glifos, mãos, ícones) |
| 13 (0x0d) | 0.5 | máscara alpha de 4 bits; firmware tinge em tempo de execução | atlas de glifos dígitos |
| 24 (0x18) | 4 | RGBA8888 | camadas de cor total (incluindo a sempre ligada aodImage) |
| 1 | — | JPEG/JFIF (ff d8 ff), extrair com qualquer descodificador | raros quadros de animação |
0a0x61NotEnvelope0aChildOverflow:61 0a 00−18/−160a.bin. ⚠️ Uma complicação configurável
é criada como N nós de grupo 0x68 empilhados no mesmo (x,y), cada um vinculado a uma
fonte diferente (os dois círculos do 275: 0x1e/0x6a/0x48/0x24/0x19 por slot — ids de opção/estilo, não
a métrica mostrada); os dois círculos são idênticos byte a byte exceto por seu retângulo + um byte de instância
(0x79/0x7a). Qual métrica aparece (PASSOS vs KCAL vs …) é estado de RAM/configuração do dispositivo, portanto uma
prévia estática não pode reproduzi-la a partir do arquivo — apenas melhor esforço.(446,0), visto nos mostradores 275/302/325/365/375)
são slots que o firmware não desenha na visão padrão — seu valor nem cabe antes da borda
da tela. Trate como oculto na prévia.0x220x22aod0x22@69,2090x60 img_number independente (cnt=10) — fonte em −5, deslocamento de um para frente. ✅ Mesmo deslocamento de um
que §11.8, mas para números não-relógio: a data do "Gradient" estava em (203,80) centro superior com fonte
0x17, mas a varredura direta 82 capturou o getter de ângulo do ponteiro vizinho (0x0a) e
a posição do ponteiro → o número renderizou no local do ponteiro com uma fonte fictícia. Correção: para um
61 0a 00 img_number em um wrapper 0x60, confie em −5/−18/−16 quando a fonte direta é
impossível para um número (fonte-0 ou um getter de ângulo de ponteiro 0x0a/0e/12/70/71/72) e a
posição −18/−16 é válida e diferente de zero (o guardião não zero pula dígitos filhos de grupo com relX=0).0x17 = data (dia do mês), 0x24 = temperatura — distintos. O mostrador 340 usa ambos (0x17
"Jun 09" e um 0x24 temperatura separado), então 0x17 é data, não temperatura. Um mostrador cujo relógio mostra
uma temperatura em um slot 0x17 é uma complicação configurada pelo usuário (estado do dispositivo), não o padrão do arquivo.| dec | cartão | dec | cartão |
|---|
| 0 | slot vazio | 49–53,97–98 | Clima |
| 1–6 | Passos | 54–58 | Timer |
| 7–12 | Calorias | 59–62 | Respiração |
| 13–18 | Em pé | 63,65–67 | Cronômetro |
| 19–24 | Atividade moderada | 68–71 | Bateria |
| 25–30 | Frequência cardíaca | 72–76 | Recentes |
| 31–36 | SpO₂ | 77–81 | Contatos |
| 37–42 | Estresse | 82–86 | Mostrador / telefone |
| 43–48 | Sono | 87–91 | Esporte (sportId ≠ 0) |
| 92 | Música | 93/94/96 | Registro de atividade / PAI / Ciclo |