
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 por 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, salvo indicação em contrário (isso reflete o firmware do
dispositivo) — cuidado com as exceções (GOALS_SET, GPS_PUSH, offset/comprimento de transferência
em massa são big-endian).
Cada afirmação não óbvia abaixo é marcada com a forma como foi estabelecida:
Quando uma seção posterior corrige uma anterior, o texto anterior é mantido com um ponteiro em vez de ser excluído — saber quais leituras foram tentadas e refutadas poupa à próxima pessoa o mesmo desvio.
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-se como CMF Watch Pro 2-XXXX
(4 caracteres hexadecimais).
Ative as notificações escrevendo 01 00 em cada CCCD (00002902-…). O canal de comando
(fff1/fff2) transporta o protocolo enquadrado abaixo. O canal de shell (77d4…) transporta
texto simples no estilo AT (ex.: AT GETSECRET; ver §14). O canal de dados (02f0…) transporta
grandes blobs binários (mostrador, firmware, AGPS), coordenados por opcodes de controle no canal de comando.
UUIDs de serviço — o relógio anuncia ~10 serviços primários. Enumerados em uma unidade real:
0xfff0 (comando), 0x180f (bateria), 0x180a (informações do dispositivo), 0xefe7, 0xffd0,
02f00000-…ffe0 e 02f00000-…fe00 (dados), 77d4e67c-2fe2-2334-0d35-9ccd078f529c (shell /
pareamento), e49a3001-f69a-11e8-8eb2-f2801f1b9fd1, f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1.
⚠️ O serviço de shell tem UUID
77d4e67c-…, não77d4ff00-…. Revisões anteriores deste documento presumiam que o serviço compartilhava o prefixoff00de suas características (77d4ff01/77d4ff02, §14) — não compartilha, pelo menos na unidade em que isso foi verificado (descoberta de freethinkel/fmc, ver §Fontes). Os UUIDs das características são inalterados. Não verificado se77d4e67cé estável entre unidades — enumere em vez de codificar.
🌐 Observação sobre Web Bluetooth. O Chromium apenas descobre serviços que a página listou em
optionalServices, mesmo para uma chamadagetPrimaryServices()sem filtro — uma página que lista 3 serviços vê 3, enquantochrome://bluetooth-internals(a própria camada C++ do Chrome, sem escopo) mostra todos os 10. Se você escrever um cliente de navegador, liste todos os UUIDs acima antecipadamente ou o pareamento falhará com serviços que claramente existem. Sem Web Bluetooth no Firefox/Safari; requer um gesto do usuário + HTTPS/localhost.
✅ Uma sessão real inteira rodou no único canal de comando — durante uma captura de 160 s de uso intenso, não houve 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** (ver §6). 🔎 confirmado no construtor de frames do app oficial (`C6117b.m30831g`).
- `chunkCount` = total de chunks para este comando; `chunkIndex` é **baseado em 1**.
- `chunkLen` = número de bytes de `chunk` neste frame.
- Uma única escrita BLE pode ser fragmentada pelo MTU do link; o receptor armazena os bytes brutos e re-extrai frames completos. Payloads grandes são divididos em múltiplos chunks (mesmo `cmd1/cmd2`, com `chunkIndex` crescente) e remontados em ordem.
### Convenção de opcode (✅ confirmado no wire)
- `cmd1 = 0xFFFF`: `cmd2` em `0x80xx`/`0x90xx` = telefone→relógio (requisição/definição); `0x00xx`/`0xa0xx` = relógio→telefone (resposta). Os pares correspondem pelo byte baixo (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- `cmd1` específico de funcionalidade: sufixo de `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** (ver §3), todo o `payloadPiece ‖ CRC` é então criptografado com AES-128-CBC/PKCS7 e esse ciphertext torna-se o `chunk` do frame.
**Peculiaridade do plaintext:** para opcodes em plaintext, o relógio *conta* o CRC de 4 bytes em `chunkLen`, mas **não** o transmite. Portanto, ao decodificar um frame em plaintext, o comprimento real dos dados é `chunkLen − 4`. (Frames criptografados carregam o CRC dentro do ciphertext, como de costume.)
Dimensionamento do chunk (para que chunks criptografados caiam em limites de bloco AES), com `maxWrite = mtu − 3`:
- criptografado: `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- plaintext: `maxWrite − 11 − 4 − 1`
✅ Todos os valores de `chunkLen` observados em frames criptografados eram múltiplos de 16 (o alinhamento de bloco é mantido).
---
## 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 via comando de 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 de nonce do relógio.Após a chave ser definida, todos os quadros do canal de comandos são criptografados com AES, exceto os opcodes de texto simples listados na §5.
✅ Ambas as derivações validadas: authkey recuperado do ntwatch.db de um telefone com root correspondeu ao
valor derivado de um rnd1/rnd2/secret capturado; sessionKey reproduzido a partir de um nonce capturado
descriptografa quadros ao vivo.
Dois caminhos de entrada compartilham a mesma cauda de nonce/confirmação.
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 Reconexão (authkey já conhecida)```
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 todo o resto expira. ✅ confirmado ao vivo (Pixel 8a): enviar os três GETs semTIME→ apenas respostas de firmware; 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 quadros são criptografados com AES uma vez que uma chave é definida, exceto estes opcodes, que estão sempre em texto simples:
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 quadros (cmd1/cmd2) sempre viajam em claro, então a sequência de comandos é visível em
qualquer captura mesmo sem a chave — apenas 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 — ver §9) | 0066 0001 |
Opcodes somente-JS (
FFFF 8051,FFFF 0051,FFFF 90A2,FFFF 90C5,FFFF A056,FFFF 908A/908Bstatus/suporte ChatGPT) são tratados no bytecode Hermes do app, não na camada Java. Seus cabeçalhos aparecem em capturas, mas a semântica do payload é ⚠️ [incerta].
Mostrador / firmware / AGPS usam um loop de init → solicitação/gravação de chunk → ack de conclusão:
(todos com 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. Ver §11–§12 para detalhes.
Payload de 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 — ver §4.3).
⚠️ Os timestamps de saúde do relógio são UTC. O app complementar deve adicionar o offset UTC local antes de derivar o dia calendário / hora do dia local. (Agrupar saúde pelo dia UTC bruto faz o dia virar na hora local errada.)
Payload de TIME_FORMAT (005F 0001) = 1 byte: 00 = 24h, 01 = 12h.
ACTIVITY_FETCH_1; o relógio responde ACTIVITY_FETCH_ACK_1 (primeiro byte 01 ⇒ pronto).ACTIVITY_FETCH_2; o relógio então envia uma rajada de quadros 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 fluxos após ACK_2), não uma
rajada única. Uma sessão pesada envia ~170–210 quadros 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. (As calorias do resumo de treino, em contraste, já estão em kcal.)
timestamp(i32 LE) ‖ valor(i32 LE)
(valor = bpm / % SpO₂ / índice de estresse).00DA 0001) é diferente — 5 bytes: timestamp(i32 LE) ‖ fc(u8).
✅ exemplo ao vivo 5e dc 29 6a 4e → ts, fc = 78 bpm. Faixas de pontuação de estresse: 1–29 / 30–59 / 60–79 / 80–99.SLEEP_DATA (cabeçalho de 18 bytes + N × registros de 8 bytes) ✅Um SLEEP_DATA = uma sessão de sono; uma noite pode conter várias (micro-despertares dividem sessões).
Cabeçalho:
Cada registro de 8 bytes: timestamp(u32) ‖ duração_s(u16) ‖ estágio(u16).
Códigos de estágio: 1 = Profundo, 2 = Leve/leve, 3 = REM, 4 = Acordado. ✅ validado contra uma noite
inteira (duas sessões, totais D/C/R/A reconciliam).
WORKOUT_SUMMARY v1 (54 bytes) / _V3 (0160 0001)v1: início(u32), fim(u32), duração_s(u32), depois tipo/calorias/passos/distância/FC-média e um
bloco GPS/estendido. ✅ layout v1 confirmado contra o 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 Room DB do app), mas os
offsets exatos de 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 para o tamanho do campo (o truncamento pode dividir um caractere
multibyte, correspondendo ao comportamento de s.encode()[:max] do firmware); campos curtos são preenchidos com zeros à direita.
0065 0001) ✅: iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ título ‖ corpo.
iconCode seleciona o ícone do app (WhatsApp=8, Telegram=12, Instagram=18, Gmail=27; desconhecido=0xFF).
Título ≤ 20 bytes, corpo ≤ 128 bytes. Enviado de um cliente → o relógio exibiu + ACK 0065 0003.005C 0001) ✅: resposta = nível(1) ‖ carregando(1) (ex.: 3b 00 = 59 %, não carregando).00DE 0001) ✅: len(1) ‖ ASCII (ex.: 10 + "CI04102520008192").0095 0001) ✅: altura_cm(1) ‖ peso_kg(1) ‖ idade(1) ‖ gênero(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 controla tudo (§4.3) — envie-o primeiro ou o relógio permanece 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) dials de foto/personalizados (uma imagem de fundo + um relógio digital desenhado pelo firmware) e (b) dials estruturados (faces integradas / da loja: um fundo mais camadas de sprites posicionadas, ponteiros e widgets de texto). Ambos transferem pelo canal de dados via o loop de init → chunk na §6.
O que realmente funciona (✅ validado ao vivo): construir um dial de foto a partir de qualquer imagem e instalá-lo; instalar qualquer um dos 103 dials da loja offline; revestir um dial estruturado (trocar o fundo ou qualquer sprite não-fundo) e mover suas camadas; reordenar / alternar a face ativa; e construir um dial estruturado do zero — o envelope de cena
0x20é decodificado e o construtor é implementado (§11.7), comprovado offline para fazer round-trip de todos os 103 dials 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 estrutural offline já cobre o que costumava causar a rejeição0a). Não há barreira de codec ou transporte e nenhuma necessidade do toolchain do fornecedor. As antigas alegações de "render estruturado é embutido em RES-pack / impossível via BLE" e "codec do lado do servidor cf=0x1f" estavam erradas (um bug de offset+bytes-por-pixel) — o firmware renderiza dials estruturados orientados por dados a partir do arquivo que você envia.
DIAL_COMMAND (9055 / a055) ✅a055 = resultado(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. Exemplo: 01 05 06 07 … = ativo #5, 6 dials, máx 7.CHANGE_DIAL (009F 0001) é inerte no fw 1.0.0.73 (retorna uma constante, não alterna) — 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)
Finish reply byte: `01` = ativado e guardado; `0a` = guardado mas **não** ativado / rejeitado. No
Android, cada `DATA_CHUNK_WRITE` tem de sair como **uma escrita BLE por frame** — concatenar e
voltar a fatiar por MTU dessincroniza os cabeçalhos e o relógio fica em loop a pedir o offset 0.
- **`9063` (foto) = APPEND.** A lista de mostradores cresce (6→7); `watchfaceId = 0xFFFFFFFF` (sentinel
personalizado) para nunca ser rejeitado como duplicado, e o relógio ativa-o automaticamente.
- **`9075` (estruturado) = SUBSTITUI** o slot `old_id`. O `old_id` **tem** de já estar na lista
(caso contrário `0a`). Para reinstalar um id já presente, **apague-o primeiro** (9055 lista-menos-id)
e depois carregue "de novo" — reutilizar um id no lugar dá `0a`.
### 11.3 Mostrador de foto / personalizado — ✅ totalmente validado de ponta a ponta
**Contentor** (round-trip verificado por byte; todos os campos 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 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
de relógio digital integrado (é sempre desenhado — não há opção "desligado"); `posX/posY` posicionam-no (valores
conhecidos como bons: 56 / 77); `color565` aplica-lhe uma tonalidade (ex.: `FFFF` = branco). ⚠️ A forma mais curta
`A5 ‖ size ‖ watchfaceId` é **rejeitada** com o finish `0a` — use o cabeçalho completo acima. (Implementação de
referência: `core-rust/engine.rs::build_wf_init2`, espelhando `C6135t.m31104u` na aplicação 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 uma com LZ4, montar o contentor acima e
carregar através do pipeline `9063` com `watchfaceId = 0xFFFFFFFF`. (Codec de referência: `core-rust/watchface.rs`,
`work/codec_dfa.py`.)
### 11.4 Mostrador estruturado / de loja — contentor e codecs ✅
**Estrutura do ficheiro** — o cabeçalho de 36 bytes é repetido **byte a byte de forma idêntica como um rodapé de 36 bytes** no EOF
(✅ verificado em 15 mostradores; um analisador deve rejeitar um ficheiro onde estes difiram):```
[36-byte header][scene TLV (§11.7)][asset pool][36-byte header again]
Cabeçalho (estrutura idêntica em todos os 103 mostradores da loja; todos os campos em little-endian):``` 0x00 crc_tree u32 LE [CRC32-raw of header[0x04:0x24] ‖ scene section] ✅ see below 0x04 magic 01 00 00 XX [XX = 0x00 or 0x02; both seen, meaning of 0x02 unknown] 0x08 name char[16] [NUL-terminated, e.g. "SlopeTime", "Metaball"; may carry a non-zero tail after the NUL (@0x17) — round-trip it verbatim] 0x18 size_a u32 LE [= filesize − 36 = footer offset = header+body] ✅ 103 dials 0x1c size_b u32 LE [asset-pool length, exactly] ✅ 15 dials 0x20 crc_assets u32 LE [CRC32-raw of the asset pool] ✅ see below 0x24 … [body starts here: the 0x20 scene container, §11.7]
> ⚠️ **Correção (substitui "não há checksum de bloqueio").** Revisões anteriores liam `@0x00` como um
> id/hash por dialeto e `@0x20` como "3× palavras u32 de id/hash \[não um CRC]", e afirmavam que CRC32/Adler32/
> soma de bytes falhavam todos na correspondência. Ambas as palavras **são** CRC32 — os testes anteriores não as detectaram porque a
> variante é não padronizada, e porque a leitura de "3 palavras em `0x20`" confundia a única palavra
> CRC com os primeiros bytes do contêiner de cena que começa em `0x24` (da mesma forma, "nome repetido em
> `0x2c`" é o nó de nome `0x86` da cena, §11.11). Descoberta de
> [freethinkel/fmc](https://github.com/freethinkel/fmc); reverificada aqui.
**CRC32-raw** = polinômio IEEE refletido `0xEDB88320`, **`init = 0`**, e **sem XOR final** — ou seja,
nem o `init=0xFFFFFFFF` nem o `^0xFFFFFFFF` do `crc32` padrão. Essa é exatamente a razão pela qual
o CRC32 pronto para uso nunca correspondeu. Observe a dependência de ordem: `crc_assets` fica dentro do intervalo
coberto por `crc_tree`, então **escreva `@0x20` primeiro, depois calcule `@0x00`**.```python
def crc32_raw(data: bytes) -> int: # tab = standard 0xEDB88320 reflected table
c = 0 # init 0, no final inversion
for b in data: c = tab[(c ^ b) & 0xFF] ^ (c >> 8)
return c & 0xFFFFFFFF
crc_tree = crc32_raw(f[0x04:0x24] + f[0x24:first_asset])
crc_assets = crc32_raw(f[first_asset:len(f)-36])
Verificado: 9/9 discos de loja imaculados correspondem em ambas as palavras, e 6/6 dos modelos deste próprio repositório
correspondem em crc_tree.
🟡 O firmware não parece impor nenhum dos CRCs. Cada disco que este repositório instalou sobre
9075— incluindo reskins produzidos pelo mesmo caminho de edição in-place de mesmo footprint (§11.6), que muta payloads de ativos e bytes X/Y sem recomputar o cabeçalho — renderizou bem no dispositivo. Portanto, um CRC obsoleto não é o que causa uma rejeição0a(essa é a invariante da janela do contêiner, §11.7). Trate os CRCs como escreva-correto-de-qualquer-forma: baratos, e o único campo de integridade conhecido no formato. Qualquer coisa que reescreva a cena ou o pool de ativos deve recomputar ambas as palavras.
Discos stub (~173 B, ex.: ids 273/274/277) são placeholders para faces gravadas na ROM: cabeçalho + diretório, sem ativos reais.
Ativos — 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ê costuma ver ali é o primeiro token LZ4 — não
pule-o). Tamanho descomprimido = w·h·bpp:
✅ Todos os 4151/4151 ativos nos 103 discos decodificam exatamente com um descompressor padrão lz4.block
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 criptografado com AES:```
kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)
`kind` = `0x02`/`0x03`; `old_id` = dial ativo atual (de `9055`); `file_len` = tamanho real do `.bin`
(= `@0x18 + 36`). Instalar um `.bin` de 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 estruturada de diretório ✅ (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]
> ```
>
> - o atributo `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` = ptr do asset; `count` 1 = imagem, 10/11 =
> atlas de dígitos — o antigo "tipo de registro `0a/0b`" era na verdade essa contagem! — 7/13/2 =
> folha de quadros de complicação).
> - extras de ponteiro: origem+escala `[src] 00 3c 00` dentro do atributo `0x01`; pivô no
> **trailer** `05 05 00 01 [pivX][pivY]`. **Centro de rotação = `(X+pivX, Y+pivY)` por ponteiro** —
> não um (233,233) fixo: existem subdials descentralizados (ex.: os ponteiros do dial 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* correto em dials analógicos cujos
> ponteiros adjacentes compartilham geometria quase idêntica. A "parede de variante compacta" (spec 24 §24.4.5) era
> esse mesmo erro de leitura. 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 sem envelope). Validado pelo oráculo `wfweb/compare.html` (render vs PNGs oficiais da loja, 99 dials): 64→72 bons, 8→5 ruins, diff 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/agulha** — mesmo registro de imagem, rotacionado em tempo de execução. **Centro de rotação = `(X+pivotX, Y+pivotY)`**
(≈ 233,233 em dials analógicos). A **fonte de dados é um `u8` no offset `+36` do registro**, 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 por desmontagem dos getters (fallback RTC 10:10:30).
- **Widget de texto/número** (`61 0a 00`): `asset_ptr(u32) ‖ [10×u16 métricas de fonte] ‖ 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 cf=5 consecutivos, ex.: `0123456789` e pontuação `,°`). ✅ renderizado.
- **Preenchimento de complicação = índice de quadro** (✅ confirmado para complicações de dígito/enum/medidor, count>1): o
valor indexa uma **folha de quadros pré-renderizada** no `.bin` — `frame = (count−1)·val/100` (percentual) ou
`frame = value` (dígito flip / enum). Tabela de quadros = sub-registro `61 ‖ count(u16) ‖ base(u32) ‖
count×id(u16)`. Ex.: a hora grande do 327 Digit Max é uma folha de 13 quadros (números 0–12), `frame = hour`.
- **Anel/arco de progresso = recorte de setor em tempo de execução** (✅ 2026-07-02, **corrige a leitura "anéis são folhas de quadros"
na spec 25 §2**): a tag de elemento **`0x81`** carrega um **único** disco completo (`61` tabela de quadros
`count == 1`), e a cunha parcial é esse disco **recortado em um setor de pizza** (`frac = value/max`,
no sentido horário a partir das 12 horas) — verificado pixel por pixel no 322 Glare 2 e confirmado `count==1` em
**20 dials**. No disco: corpo `0x81` = sub `0x01` (geometria `x@+0 y@+2 w@+4 h@+6`, `61 1 base` inline
= disco) + sub `0x5b` (spec de arco). Implementado no wfweb (`blendSector`).
⚠️ O sub-registro `0x5b` **não** é apenas "`max` u16 `@+4`" como documentado anteriormente — isso lia a
metade baixa de um `max i32` e perdia os **ângulos de varredura inicial/final e a largura do traço** que ficam logo
depois. Há também um irmão procedural `0x80`/`0x5a` (com raio explícito) que este
documento nunca cobriu. Layout completo do registro, e o que a suposição anterior "sentido horário a partir das 12 horas"
errou: **§11.15**.
- **Id da fonte de dados** — o bloco de atributos `82` do elemento fica 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`, `anchor@+0x0C/0E`,
`mode@+0x15`, `frame-count@+0x1A`). Anchor < 0 = alinhar à borda do pai. 🔎 O firmware resolve
o id por meio de uma tabela de getters de 142 entradas em `0x101f371c` (cada uma chama `ux2sys_get(type)`).
⚠️ **Prefira ler o id como `meta[9]` da struct (§11.11)** — um campo fixo — em vez desta varredura
de `82`-attr para frente, que é a fonte do off-by-one descrito em §11.8/§11.9. Tabela completa de ids em §16; observe
que os rótulos de saúde/clima que esta seção listava inline anteriormente (`0x19` FC, `0x1b` bateria,
`0x24` temperatura, `0x36` passos) são **contestados e provavelmente errados** — veja a caixa ⚠️ em §16.
(O exemplo de grupo `0x07:0x0b:0x0f` = HH:MM:SS abaixo não é afetado.)
- **Nó de grupo** (`0x68`): aninha seus filhos dentro do próprio corpo TLV (`0x60` = valor/texto,
`0x30` = estático); cada `0x60` carrega seu id de fonte em `data+16`. Ex.: um grupo `0x07:0x0b:0x0f` =
relógio HH:MM:SS. Parser de elemento TLV = `0x100db55c` (tabela de salto indexada por `tag−0x70`).
### 11.6 Matriz de autoria
| Caminho | Status | Notas |
|---|---|---|
| Dial de foto a partir de qualquer imagem | ✅ **feito** | §11.3; validado no dispositivo |
| Instalar qualquer um dos 103 dials da loja | ✅ **feito** | §11.4; `9075`, `old_id`=ativo |
| Revestir o fundo cf=4 de um dial 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-autorar por modelagem (trocar pixels de qualquer camada + mover geometria) | ✅ **renderiza via BLE** | dial 373: bg→ciano + um sprite cf=5→vermelho + X movido 224→100, tudo renderizado, agulhas ao vivo |
| Dial estruturado 100 %-sintético do zero | ✅ **builder feito, validado offline** | builder de envelope `0x20` em `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`); round-trip de todos os 103 dials byte-exato + sintético passa no validador do firmware (§11.7). 🟡 render no dispositivo sobre `9075` ainda não filmado |
| Fontes do sistema (`.font`) | ✅ **decode/render (todas)** | bin LVGL (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` (SINGLE/REPEATE/COUNTER de 3 estados + pré-filtro XOR por linha), portado 1:1, sem desmontagem |
⚠️ Armadilhas de revestimento/re-autoria que causam tela preta ou `0a`: deixar o **`len` antigo do asset** (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; **reordenar assets** sem corrigir a cadeia de tamanho de bloco da cauda de referência
(§11.11). Não é fatal hoje, mas escreva corretamente de qualquer forma: qualquer mudança na cena ou no pool de assets
invalida as duas palavras **CRC32** do cabeçalho — recalcule ambas (§11.4), `@0x20` antes de `@0x00`.
### 11.7 O envelope de cena `0x20` — decodificado e builder implementado ✅
Um dial **real** re-autorado renderiza porque preserva o envelope de cena do arquivo. Um corpo puramente sintético
de registros planos `61 …` é **rejeitado** — o parser do firmware (`WFManager_Parser`, `0xdb35c`)
exige que o corpo (do offset `0x24`) comece com um contêiner 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 é um TLV aninhado limpo — [tag u8][len u16 LE][body], tags de contêiner 0x20/0x21/0x22/0x68
recursivas, drawables folha 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 dos drawables; o
parser antigo os encontrava heuristicamente — e costurava corpos adjacentes, veja a revisão §11.5.
O layout do corpo do drawable agora está totalmente decodificado lá.) O offset+len de cada filho deve caber dentro da
janela do pai;
primeiro byte do corpo ≠ 0x20 → erro de parser −16; um filho ultrapassando sua janela → −2; qualquer um faz o
handler 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 por byte (os len aninhados recalculados coincidem) e validate_container passa em todos.build_reframe_identity / CLI reframe — remontar o .bin inteiro do zero reproduz
o arquivo byte por byte exceto 1 byte de padding de nome (@0x17; não é um checksum).build_container_synthetic — compõe um novo mostrador (fundo + drawable aninhado em 20→21) que
passa a 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 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 sobre todos os 103 mostradores) e fechou-se quatro lacunas:
i16 (com sinal). ✅ Âncoras podem ser negativas para elementos que
se estendem para fora do canvas — ex.: o ponteiro vermelho de segundos do 275 está em Y = 0xFFFC = −4 (um sprite 30×281,
fonte 0x12, rotacionado do centro para fora da borda superior). Ler X/Y como u16 (65532) fazia o
guarda 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 de registro −5 — a varredura direta de atributos 82 está
sistematicamente deslocada em um aqui e captura o atributo do próximo irmão (no 275 o dígito de minuto
pegou o 0x18 do dia da semana). 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 re-exportação corrompe esses bytes (quebra
mesma pegada → ).Também: as miniaturas oficiais da loja são renderizadas às 10:10 (horário clássico de marketing), não 10:12 — igualar o horário do oráculo a 10:10 reduz a diferença média de pixels visivelmente. O parser do wfweb agora faz round-trip de todos os 103 mostradores byte-exato (a correção de offset de escrita X/Y acima limpou as últimas incompatibilidades).
0x22 em sua própria visualizaçã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 always-on (AOD) de cada elemento como uma camada normal. No "Gradient" o atlas de data cinza AOD
(offset em 0x22) desenhava por cima do vermelho normal. Correção: marque cada registro 0x22 com
layer.aod=true (com seu próprio conjunto de deduplicação) e deixe renderAt(…, aod) mostrá-los apenas no modo AOD
(o modo normal oculta 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 sobredesenhando muitos mostradores — e o alternador AOD do editor agora mostra o layout
always-on real em vez dos normais. O AOD real é uma tela preta (sem cena escurecida):
se o mostrador não tem um quadro de fundo AOD dedicado (dial.aod), a cena normal é ocultada no modo AOD
para que renderize preto + os elementos em sua própria cor. Os AOD são analisados pelo
caminhador de cena também (agora ele recorre no contêiner marcando drawables , em vez de deixá-los
para a varredura plana onde seu pivô não correspondia → "sem posição"); ponteiros AOD giram no
centro do canvas (o às vezes carrega um x/y de ponteiro descentralizado que o firmware ignora — ex.:
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 é
byte-idêntica em todo o processo; o roundtrip permanece byte-exato 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ígitos (0 ⇒ padrão do firmware 7).0x80 = zero à esquerda (mostrar zeros iniciais, ex.: "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 iniciais suprimidos a menos que bit7. O u16 após a fonte (60 para
data, 1000 para kcal) NÃO é a contagem — ele só alimenta a inserção do glifo separador de milhares/milhões
(cmp #1000/#1000000), que é por que editá-lo não fazia nada. O id da fonte também não limita.
O 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. Então o campo de data
40 01 00 82 = 2 dígitos, com zero à esquerda — essa é a razão inteira pela qual uma temperatura Fahrenheit reatribuída
(≥100) era truncada.
Correção / editor: o wfweb analisa digitCount/digitZeroPad (+digitCountOff) para campos numéricos,
expõe "Dígitos" + "Zero à esquerda" no inspetor, escreve o byte no lugar (mesma pegada),
e a pré-visualização limita/preenche para digitCount para espelhar o firmware. Então reatribuir a fonte de um campo e
definir sua contagem de dígitos funciona para qualquer campo (ex.: data→temperatura °F → Dígitos 3). Oráculo
em modo normal inalterado (0 regressões, 3 pequenas melhorias); roundtrip byte-exato em todos os 103 mostradores.
(A hipótese anterior de "largura de Dígitos"/rectW estava errada — largura é apenas layout, não a contagem.)
struct, e a cauda de referência de recurso ✅A cena (§11.7) é um TLV aninhado limpo — [tag u8][len u16 LE][body]. Inventário completo de tags como
observado no corpus:
✅ Este inventário está completo para o corpus. Recorrendo apenas nas tags de contêiner acima, o
TLV de cena de todos os 15 mostradores verificados caminha exatamente até seu comprimento de raiz declarado com zero tags
desconhecidas — então um parser que lida com esta tabela lida com o formato inteiro, e uma tag desconhecida significa uma
leitura desalinhada, não um novo tipo de nó. (Cuidado: um caminhador que recorre em todo nó cujo comprimento
acontece de ser ≥ 3 descerá em corpos struct/0x5b e alucinará uma longa cauda de "tags" únicas
— os corpos folha não são TLV.)
💡 Atalho de autoria: o auto-layout
0x48/0x68pode ser pulado inteiramente — cada widget pode ser colocado comx,yabsoluto diretamente no nível superior da tela, que é o que o builder do zero (§11.7) faz. Só é necessário para ler mostradores existentes. Uma largurametade0x8000marca um struct como um filho de auto-layout de um quadro (a posição vem do pai, não dex,y).
Corpo do struct 0x01 — um prefixo fixo de 18 bytes seguido por uma cauda opcional de referência de recurso:```
+0x00 x i16 [signed — can be negative, see §11.8]
+0x02 y i16
+0x04 meta[14] ────────────────────────────────────────────────────
meta[0..1] w u16 [0x8000 = auto-layout child of a frame]
meta[2..3] h u16
meta[4..6] unknown [placeholder-looking (1,0,0)/(4,0,0); see §11.14]
meta[7] accent-tint capability flag — 4 = tintable (§11.14)
meta[9] DATA SOURCE ID (§16)
meta[10] sub / variant
meta[11..13] max u24 LE [the metric's nominal full-scale value]
+0x12 ref tail [61 …] — absent on imageless rings (0x80/0x81, §11.15)
> ✅ Isto unifica os "offsets mágicos" das §11.5/§11.8/§11.9. Essas seções localizam campos *relativos
> ao byte `0x61` da tabela de frames* — que é simplesmente `+0x12` desta struct, portanto `−18`/`−16` = `x`/`y` e
> **`−5` = `meta[9]`, o id da fonte**. Mesmos bytes, um layout limpo. A heurística de varredura direta do atributo `82`
> que estava sistematicamente deslocada por um não é necessária: leia `meta[9]` da struct.
> O `max` de um campo numérico é igualmente apenas `meta[11..13]` (por exemplo, campos de dia do mês carregam `max = 99`).
**Cauda de ref** (`61`) — como um nó aponta para seus bitmaps:```
+0x00 0x61 [tail type]
+0x01 count u16 [1 = single image · 10/11 = digit atlas · N = pick-list / frame sheet]
+0x03 base u32 [ABSOLUTE FILE OFFSET of the first asset block]
+0x07 count × u16 = the BLOCK SIZE (8 + payload len) of each referenced asset, in order
⚠️ Esses u16s finais foram anteriormente documentados como "count×id(u16)" / ids de glifos. Eles são
tamanhos de bloco: base, mais a soma acumulada deles, percorre o pool de assets entrada por entrada (✅ verificado
exatamente para todas as 10 entradas de um atlas de dígitos). Duas consequências:
count−1 tamanhos são essenciais; o valor da última entrada nunca é seguido, então
arquivos encontrados na natureza às vezes carregam um valor obsoleto ali. Não trate uma incompatibilidade na entrada
final como uma referência quebrada.0x28 preview — a miniatura da loja/catálogo é embutida no próprio .bin (27 nós de preview
em 15 mostradores), como um pvStruct 0x08: um prefixo de 5 bytes mais a mesma cauda de referência, com sem x/y.
Útil para construir uma UI de galeria sem enviar PNGs separados.
0x02 ✅O irmão 0x02 de um widget o torna condicional. Sem ele, o widget sempre é desenhado. Gramática:```
count u8 , count × ( id u8 , op u8 , val u24 LE signed ) [5 bytes per entry]
`id` é um id de fonte de dados (§16) — incluindo os **ids de slot sintéticos** do §11.13. Operadores, com
contagens de ocorrência medidas em 15 mostradores:
| op | significado | visto |
|---|---|---|
| `0x01` | desenhar se `value == val` | 99 |
| `0x81` | igual a `0x01` (bit `0x80` definido — aparece em variantes mutuamente exclusivas) | 48 |
| `0x02` | **ocultar** se `value == val` | 7 |
| `0x03` | desenhar se `value == val`, onde `val` é um marcador de **sem dados** (ex.: HR `1000`) | 13 |
| `0x05` | desenhar se `value >= val` | 58 |
| `0x06` | desenhar se `value <= val` | 50 |
| `0x04` | ⚠️ **desconhecido** — 15 ocorrências, sem semântica confirmada | 15 |
Regra de combinação (conforme implementada pelo renderizador de referência, máscara `op & 0x7f`): as entradas
de igualdade são combinadas com **OR**, e depois as entradas de ocultar/`>=`/`<=` devem **todas** ser válidas.
Este único mecanismo cobre a maior parte da variabilidade em tempo de execução do formato e explica estruturas que parecem
widgets duplicados:
- **Layouts 12h / 24h e métrico / imperial** — dois conjuntos de widgets empilhados no mesmo ponto, cada um vinculado a
`id 0x73` (o sinalizador de unidades) com `val` 0 ou 1. O mostrador 275 tem seis desses pares (12 nós).
- **Espaços reservados de "sem dados"** — op `0x03` contra um sentinela, ex.: `id 0x5f, val 1000` (mostrador 275, duas vezes):
desenha a arte de travessão em vez de uma temperatura quando a métrica não está disponível.
- **Destaques em intervalos** — um intervalo pareado `0x05`/`0x06`, ex.: a corrente do Metaball onde cada elo acende
para sua própria janela de 5 minutos.
- **Alternativas de slots de complicação** — vinculadas aos ids de slot sintéticos do §11.13.
### 11.13 Slots de complicação configuráveis — `0x85` + `0x5f` ✅ (substitui §11.8)
O §11.8 concluiu que a métrica ativa de uma complicação configurável é estado de RAM do dispositivo e não poderia ser
recuperada do arquivo. **Isso estava errado** — tanto o menu de métricas do slot quanto sua seleção padrão
estão no `.bin`. Cada nó `0x85` carrega um irmão `0x5f`:```
+0x00 slotIndex u8 [0-based position among sibling 0x85 nodes]
+0x01 count u8 [how many metrics this slot offers]
+0x02 activeIdx u8 [index into the list below = the DEFAULT SHOWN METRIC]
+0x03 count × u8 [the metric ids themselves (§16)] … NUL padding
As alternativas que realmente são desenhadas são grupos comuns de 0x68 em outras partes da árvore, cada um controlado por
uma condição 0x02 (§11.12) no id sintético 0x79 + slotIndex — então as variantes do slot 0 dependem de
0x79, as do slot 1 de 0x7a, e assim por diante. Para renderizar um slot: leia activeIdx e desenhe a variante cuja
condição corresponda a esse índice.
Medido em mostradores reais:
Os dois slots de 6 métricas do mostrador 275 respondem por 12 dos seus 26 nós 0x02, exatamente como previsto:
01 79 81 0X 00 00 e 01 7a 81 0X 00 00 para X = 0..5 — seis alternativas chaveadas em 0x79 (slot 0)
e seis em 0x7a (slot 1). (Os bytes 0x79/0x7a que §11.8 chamou de "byte de instância" são esses ids de
vínculo.) Os 14 restantes não estão relacionados: 12 em 0x73 (o sinalizador de 24h/unidades de métrica, val 0 ou 1 — seis
pares de widgets alternando entre layouts de 12h e 24h) e 2 em 0x5f com op 0x03 e val = 1000,
o placeholder de sem dados de temperatura.
Ainda é genuinamente estado do dispositivo: o que o usuário escolher depois no aplicativo complementar substitui
activeIdx em tempo de execução, então uma pré-visualização reproduz o padrão do arquivo, não necessariamente o que um
relógio específico mostra. imgs[0] de um nó 0x85 é um placeholder "toque para configurar" que o firmware desenha apenas
no seu próprio modo de edição — ignore-o ao pré-visualizar a exibição normal de horas.
meta[7] == 4 ✅Alguns mostradores permitem que o usuário escolha uma cor de destaque no dispositivo, e o firmware a substitui nos
bitmaps do widget em tempo de renderização. O interruptor é um único byte: meta[7] da struct (§11.11) —
ou seja, o byte +0x0B do corpo 0x01 — igual a 4 marca o(s) recurso(s) desse widget como tingíveis.
.bin enviado deve manter seus pixels originais ou você perde permanentemente a escolha do usuário. Aplique o
tingimento apenas no caminho de pré-visualização/canvas.⚠️ Não "melhore" isso em uma heurística de cor — esse caminho é um beco sem saída comprovado (documentado por fmc depois de fazê-lo do jeito difícil). A teoria intuitiva é que pixels sinalizados são gravados em alguma cor de placeholder reconhecível que o firmware troca. Não pode funcionar: o anel tingível do mostrador 348 Tumbler e as faixas de dígitos comuns não tingíveis dos mostradores 282 Radar Sweep / 291 Vertical gravam o exatamente mesmo RGB
(255,72,32)(verificado exaustivamente, cada pixel); e os mostradores 305 Dots (ponteiro de horas) e 306 Large Number (dígitos) são tingíveis enquanto gravados em branco puro, então um teste de cor os perderia completamente. Refinamentos sucessivos (1 → 4 cores de referência, mais uma lista de permissões de função de widget) todos falharam. Leia o sinalizador.Verificado cruzadamente contra o dispositivo real / aplicativo complementar em 7 mostradores, escolhidos para estressar ambas as direções — 349 Theatre, 376 Digits time, 305 Dots, 306 Large Number, 304 Elaborate 2 todos oferecem a configuração de destaque e todos têm widgets
meta[7]==4; 316 Trailing (ponteiro avermelhado, sem configuração), 312 Disc e 295 Vortex não oferecem nenhuma e têm zero widgets sinalizados.
meta[4..6] fica logo ao lado do sinalizador e parece que pode codificar uma cor em algumas structs
(um RGB de aparência real com cauda f1=1,f2=255, vs. o placeholder (1,0,0)/(4,0,0) das structs sinalizadas).
Isso não se correlaciona com a capacidade de destaque. ⚠️ Não resolvido; ignore.
0x80/0x5a (procedural) e 0x81/0x5b (recorte de imagem) ✅Ambas as variantes de anel emparelham uma struct curta (x, y, meta com o id de origem — geralmente sem
cauda de ref nenhuma) com um irmão de especificação de arco. §11.5 documentou apenas "0x5b: max u16 @+4", que é
a metade baixa de um max i32 e perde a geometria do varrido. Registro completo:```
+0x00 min i32 LE [always 0 in the corpus]
+0x04 max i32 LE [100 in the corpus, except dial 332 = 60]
+0x08 start i16 LE [sweep start, units of 0.1° — SIGNED]
+0x0a end i16 LE [sweep end, units of 0.1° — SIGNED]
+0x0c width u16 LE [stroke width in px]
+0x0e radius u16 LE [0x5a ONLY — 0x81 takes its radius from the clipped image]
+0x0e / +0x10 trailer 01 00 kk ⚠️ unresolved (see below)
O emparelhamento é rígido: `0x80` carrega sempre exatamente `0x01` + um **`0x5a` de 19 bytes**, `0x81` sempre exatamente
`0x01` + um **`0x5b` de 17 bytes** (✅ 26/26 anéis em 15 mostradores). ⚠️ Mas o **`0x81` recortado por imagem
domina** — 25 desses 26. A variante processual `0x80`/`0x5a` apareceu **uma vez** (mostrador 273), então seu
campo `radius` e layout de 19 bytes dependem de uma única amostra; trate com suspeita até ser visto novamente.
`frac = clamp((valor − min) / (max − min), 0, 1)`, e o arco preenchido vai de `start` em direção a `end`.
O ângulo zero está às **3 horas**, positivo no sentido horário. Exemplos medidos:
| mostrador | tag | min..max | start → end | largura | raio |
|---|---|---|---|---|---|
| 273 Activity Mood | `0x5a` | 0..100 | **−102,8° → 102,8°** | 42 | 222 |
| 273 Activity Mood | `0x5b` | 0..100 | 270,0° → 90,0° | 80 | (imagem) |
| 276 Dichotomy | `0x5b` | 0..100 | 60,0° → −120,0° | 23 | (imagem) |
| 304 Elaborate 2 | `0x5b` | 0..100 | −2,0° → 358,0° | 24 / 80 | (imagem) |
| 366 Combo | `0x5b` | 0..100 | 0,0° → 270,0° | 18 | (imagem) |
| 368 Function | `0x5b` | 0..100 | 0,0° → 360,0° | 20 | (imagem) |
> ⚠️ **Isto corrige a suposição "sentido horário a partir das 12 horas"** na §11.5. Esse é apenas o caso
> especial `start = 0, end = 3600` (uma varredura completa, onde a convenção é inobservável). Mostradores reais usam
> **medidores parciais** (o leque de ±102,8° do 273, o anel de três quartos de 270° do 366) e **varreduras negativas**
> (60° → −120° do 276), então um renderizador que sempre varre um círculo completo a partir do topo desenha esses errado.
> 🟡 A convenção exata de ângulo zero e a regra de direção vêm do renderizador do fmc, verificadas de forma cruzada
> contra esses valores em disco — não verificadas de forma independente pixel por pixel no dispositivo por este repositório.
> Observe que `frac = valor/max` e o recorte de setor `0x81` na §11.5 **foram** validados (mostrador 322).
⚠️ **Não resolvido: o trailer de 3 bytes `01 00 kk`.** `kk` assume valores de aparência plausível (104, 152, 216,
232, 248) e a hipótese óbvia é um raio — **testada e refutada**: o mostrador 366 usa `kk = 104`
para um anel de 82×82 e um de 166×166, e o mostrador 273 usa `kk = 232` para um anel de 440×440 e um de 284×284.
Não é um raio, não é um diâmetro. Possivelmente opacidade/estilo. Faça o round-trip verbatim.
🟡 **Pegadinha relatada, não verificada aqui:** no renderizador do fmc, um número `0x60` cujo id de origem seja igual ao
id de **qualquer** anel na mesma tela renderiza `round(frac × 100)` em vez do valor bruto — então um
número de frequência cardíaca ao lado de um anel de frequência cardíaca mostra `36` em vez de `71 bpm`. A
solução documentada por eles é colocar o anel e o número em dois ids **alias** da mesma métrica (passos
`0x19`/`0x26`/`0x49`, calorias `0x1c`/`0x1e`/`0x48`). Se isso é comportamento do firmware ou específico
do renderizador deles **não está estabelecido** — vale uma verificação ao vivo antes de projetar em torno disso.
---
## 12. Detalhes de transferência em massa e OTA
A tabela de transferência está na §6. Pontos adicionais confirmados:
- **AGPS/EPO** ✅: o primeiro bloco gravado começa com o cabeçalho ASCII `000000010000…`. O loop completo
init → `[A05F ↔ 905F]×N` → finish foi observado no fio (~892 blocos).
- **OTA de firmware** (`9040`–`9042`, finish `9041`) 🔎: estrutura mapeada; payload INIT2 = bytes de versão
(ex.: `0b 00 00 39` = 11.0.0.57). **Não testado em campo** (o app desativa a atualização de FW aqui). As imagens
de firmware parecem ser **não assinadas — a integridade é apenas CRC32** (nenhuma assinatura assimétrica observada na RE).
- ⚠️ Como OTA e `FACTORY_RESET (009A 0001)` compartilham a sessão autenticada, uma única autenticação BLE
válida é suficiente para apagar ou (em princípio) brickar o relógio. Manuseie com cuidado.
---
## 13. Sensores
✅ Hardware exposto via BLE:
- **PPG óptico** — frequência cardíaca (manual/automático/treino/repouso), SpO₂ e estresse derivado de VFC.
- **Acelerômetro de 3 eixos** — passos, distância, calorias, estágios do sono, levantar o pulso, cadência.
- **GNSS/GPS** (assistido por AGPS) — trilha de treino (`WORKOUT_GPS`) e envio de localização (`GPS_PUSH`).
**Não há** barômetro/altímetro, bússola, giroscópio ou sensor de temperatura de pele/corpo. Um termistor
NTC interno (temperatura da placa/bateria) existe, mas é legível **apenas** pelo 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`, ex.: `"BRA 2x1 ARG"` apareceu no widget) e os campos de **faixa/artista**
de música; 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.
---
## 14. Canal AT de fábrica / shell (`77d4ff01` / `77d4ff02`)
Um canal de comandos AT em texto puro separado, independente do protocolo enquadrado. ✅ testado ao vivo:
- **Leitura:** `AT GETSECRET` (segredo de emparelhamento de 16 bytes), `GETVERSION`, `GETSN`, `GETNAME`, `GETPID`,
`GETBATLV` (mV bruto, ex.: `3853mv`), `GETGSENSOR` (aceleração bruta em g, `X=… Y=… Z=…`),
`GETNTCTEMP` (°C, NTC interno).
- **Escrita / atuação:** `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.
---
## 15. Recursos bloqueados por firmware / indisponíveis (🔎 RE de firmware)
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 de firmware, que está fora do escopo aqui:
- **Voz ChatGPT** — gate = id de recurso `ux2sys` `0x9e`, semeado de NVRAM/EFUSE/região na inicialização; neste
SKU o flag de suporte `908b = 00`. Não influenciável por telefone, conta ou BLE (confirmado por
experimento + RE). O app é apenas um relé; o áudio vai do telefone → nuvem da Nothing.
- **Pressão arterial** — um subsistema completo existe no firmware, desligado por SKU/região.
- **Alipay / pagamento NFC** — UI completa presente, apenas para SKU da China.
- **Ausente em hardware/firmware:** ECG, SOS/emergência, NFC genérico.
---
## 16. Ids de fonte de dados / getter de complicação
Não é necessário para construir um cliente BLE, mas **essencial para criar ou renderizar um mostrador**: este é o valor de
`meta[9]` (§11.11) que vincula um widget a dados ao vivo, o `id` de uma condição de visibilidade (§11.12) e
as entradas do menu de métricas de um slot (§11.13). O firmware o resolve por meio de uma **tabela de despacho de getter
com 142 entradas** em `0x101f371c`, cada entrada chamando `ux2sys_get(type)` (🔎 RE de firmware).
### Hora / data — ✅ bem estabelecido
| id | significado | id | significado |
|---|---|---|---|
| `0x01` | hora (12/24h conforme configuração do dispositivo) | `0x0f`, `0x12` | segundo (suave) |
| `0x04` | hora (24h) | `0x10`, `0x11` | dezenas / unidades de segundo |
| `0x07` | hora (24h forçado) | `0x71`, `0x72` | segundo (tique-taque / ângulo do ponteiro) |
| `0x02`, `0x03` | dezenas / unidades de hora-12h | `0x13` | flag AM/PM (0 = AM, 1 = PM) |
| `0x05`, `0x06`, `0x08`, `0x09` | dezenas / unidades de hora | `0x15`, `0x16` | mês |
| `0x0a`, `0x70` | ângulo do ponteiro de hora | `0x17` | dia do mês |
| `0x0b` | minuto | `0x18` | dia da semana (0 = segunda-feira ⚠️) |
| `0x0c`, `0x0d` | dezenas / unidades de minuto | `0x0e`, `0x71` | ângulo do ponteiro de minuto |
Ids de dezenas/unidades desenham um **único dígito** — um widget vinculado a um deles renderiza um glifo, não o valor
inteiro (mostrador 284 Square). Ids de ângulo de ponteiro (§11.5) são `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).
### Saúde / sensores / clima — ⚠️ contestado, leia a coluna de evidências
| id | significado (melhor leitura atual) | evidência |
|---|---|---|
| `0x19` | **passos** | no menu de slots do 368 junto com `0x1a`; corresponde ao `parse.ts` deste repositório |
| `0x1a` | **frequência cardíaca** | no menu de slots do 368 junto com `0x5f` e `0x19` |
| `0x1c` | calorias | menu de slots, ícone de chama no app complementar |
| `0x1e` | calorias (alias) | corpus |
| `0x22` / `0x23` | distância km / mi (parte inteira) | corpus |
| `0x74` / `0x75` | distância km / mi (parte fracionária) | corpus |
| `0x76` | distância (forma de slot) | menu de slots, ícone de estrada |
| `0x24` | **% de bateria** | menu de slots, ícone de raio |
| `0x30` | % de bateria | corpus |
| `0x36` / `0x5f` | temperatura | `0x5f` = menu de slots, ícone de nuvem-sol; 361 TempoG vincula um número simples |
| `0x48` | permanências (horas em pé) | menu de slots, ícone de figura em pé |
| `0x8b` | AQI | menu de slots |
| `0x73` | flag de 24h / unidades métricas | corpus |
| `0x25`–`0x27`, `0x49`, `0x6c`, `0x6f` | % de meta / aliases de slot de passos e calorias | corpus |
| `0x6a` | ⚠️ métrica de slot não identificada | aparece em 4 menus de slots |
| `0x79 + slotIndex` | **sintético** — não é uma métrica; o id de seleção de slot (§11.13) | ✅ §11.13 |
> ⚠️ **Três tabelas neste repositório discordavam; esta é a reconciliação.** Revisões anteriores da §16
> e da §11.5 liam `0x19` como frequência cardíaca, `0x1b` como bateria, `0x24` como temperatura e `0x36` como passos —
> e `wfweb/src/codec/mock.ts` ainda codifica essa leitura, enquanto `wfweb/src/codec/parse.ts` codifica uma
> diferente (`0x19` passos, `0x24` % de meta, `0x48` permanências, `0x1a` clima). A tabela acima segue
> a leitura com melhor evidência (rótulos calibrados pelo fmc contra os ícones do menu de slots de widget do próprio app
> complementar, veja §Fontes). **O código ainda não foi alterado — `mock.ts` e `parse.ts` ainda estão
> inconsistentes entre si e com esta tabela.** Trate os rótulos de ids de saúde como ⚠️ até que alguém vincule
> um campo a cada id e leia o relógio.
>
> A evidência individual mais forte é o **menu de slots do mostrador 368 Function** (§11.13), que oferece
> `0x5f 0x1c 0x19 0x48 0x24 0x76 0x1a 0x8b` como **oito métricas distintas selecionáveis pelo usuário** em um menu.
> Quaisquer que sejam os rótulos, nenhum par desses oito pode ser a mesma métrica — o que descarta
> `0x19` = `0x1a` = frequência cardíaca e `0x24` = `0x5f` = temperatura simultaneamente.
Complicações de anel/arco com uma **folha de quadros** indexam um quadro pré-renderizado (ex.: 50 % = quadro 50 de 100)
embutido no `.bin` que você envia (§11.5), então nenhum pacote RES externo é necessário; anéis sem imagem são desenhados
a partir da especificação de arco (§11.15).
---
### Quick-cards (tiles da tela inicial) — `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 (sem canal de conteúdo). Primeiro byte do payload = sub-comando: `00` = GET, `01` =
SET; **ambos usam `0x906D`** (`0x906C` está listado, mas não é usado — consultá-lo dá timeout). Resposta = `A06D`.
> 🛑 Enviar um `assemblyId` inventado **apaga 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 app 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 Sport, sportId 2).
**Slots:** cada tela tem **4 slots**. O tipo de um cartão define seu tamanho — `circular`/`quadrado` = 1 slot,
`retângulo` = 2 slots. A validação é pura aritmética de slots (Σ ≤ 4 por tela); sem cartões mutuamente exclusivos.
`sportId` é `0` exceto em cartões Sport (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):
| 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 | Permanência | 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 | Sport (`sportId` ≠ 0) |
| 92 | Música | 93/94/96 | Registro de atividade / PAI / Ciclo |
---
## Apêndice A. Catálogo de mostradores de estoque (id → nome)
A §11 refere-se a mostradores por id numérico em todo o texto (275 SlopeTime, 322 Glare 2, 357 Silhouette, …). Os ids
`273`–`376` são as faces da loja/estoque; o id é o que `DIAL_COMMAND (9055/a055)` relata como ativo e
o que `9075` aceita como `old_id` (§11.4). 100 dos 103 ids conhecidos estão nomeados abaixo; os restantes são
stubs de ROM (§11.4). Nomes e agrupamento conforme mostrado pelo app complementar oficial.
- **Padrão** (6) — `273` Activity Mood · `274` Sun Circle · `275` SlopeTime · `276` Dichotomy · `277` Prismatic Time · `280` Multifunction
- **Analógico** (34) — `286` Sundial · `287` Simple Dial · `292` City · `294` Sudoku · `305` Dots · `306` Large Number · `309` Gradient · `310` Glare · `311` Bold · `313` Classical · `314` Fragment · `315` Infinite · `316` Trailing · `322` Glare 2 · `326` Chrono Master · `327` Digit Max · `328` Coherent · `329` Wheel · `330` Zenith · `331` Intersection · `335` Time Phase · `336` Energetic · `338` Chronos · `341` Dual View · `346` Time Windmill · `347` Large Panel · `349` Theatre · `352` Elegant Sweep · `360` Explorer · `364` SportPulse · `370` Hemisphere · `371` ActiveTrio · `372` Time Wheel · `373` Traditional Pointer
- **Digital** (41) — `281` Metaball · `282` Radar Sweep · `283` Radio · `285` Widgets · `288` Type · `289` Rotate · `290` Gradual · `291` Vertical · `293` Stairs · `296` Ladder · `297` Ray · `298` Eclectic · `299` Echo · `300` Mono Dial · `301` Orbit · `302` Calendar · `303` Space · `307` Sprung · `308` Sundial 2 · `319` One Line · `320` Orienteer · `321` Revolution · `323` Dash · `324` Finesse · `325` Metric · `333` Circularity · `334` Globe of Time · `337` Time Finder · `339` Suprematism · `340` Sport Mode · `345` Time Dot · `350` Timeline · `351` Cyclopes · `353` Dual phase · `357` Silhouette · `359` Ring data · `361` TempoG · `362` Steady · `365` Elegance · `369` Solar System · `376` Digits time
- **Multifunção** (10) — `304` Elaborate 2 · `344` InfoMeter · `348` Tumbler · `354` Dual · `363` Vintage · `366` Combo · `367` Complex Figure · `368` Function · `374` Cirquary · `375` InfoHub
- **Criativo** (8) — `284` Square · `312` Disc · `317` Disc 2 · `318` Dominos · `332` Flux · `342` Perfect Match · `343` Progress Day · `358` Asteroid
- **Diwali** (1) — `295` Vortex
---
## Fontes
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 deste projeto vive em
`core-rust/src/{commands,frame,crypto,health,session}.rs` (Rust), no editor TypeScript `wfweb/` e nas
ferramentas Python `cmftool/` (`pair.py`, `session.py`, `wf_codec.py`, `upload_custom.py`, …).
**Trabalho independente incorporado aqui.** [freethinkel/fmc](https://github.com/freethinkel/fmc) — um
editor de mostradores SvelteKit + marketplace para o mesmo relógio — fez engenharia reversa de forma independente do
formato `.bin` a partir de um corpus de ~100 faces e alcançou vários resultados que este documento não tinha ou tinha
errado. Os `docs/cmf-protocol.md`, `src/lib/modules/editor/lib/{wf,render}.ts` e
`src/lib/modules/device/lib/ble.ts` deles valem a leitura direta. Descobertas adotadas, cada uma re-verificada
contra bytes de mostradores antes de ser escrita aqui:
| Descoberta | Onde | Status aqui |
|---|---|---|
| Ambas as palavras do cabeçalho são CRC32 (variante não padrão) | §11.4 | ✅ re-verificado 9/9 mostradores; **corrige** "sem checksum de bloqueio" |
| Condições de visibilidade, tag `0x02` | §11.12 | ✅ re-verificado; era não documentado |
| Lista de métricas de slot + `activeIdx` padrão, `0x79 + slotIndex` | §11.13 | ✅ re-verificado em 275/368/273/304; **substitui** §11.8 |
| Flag de capacidade de tint de destaque `meta[7] == 4` | §11.14 | ✅ prevalência re-verificada; era não documentado |
| Especificação completa de arco (ângulos de varredura, largura, raio) | §11.15 | ✅ re-verificado; **corrige** "`max` u16 `@+4`" |
| `u16`s da cauda de ref são tamanhos de bloco, não ids de glifo | §11.11 | ✅ re-verificado exato em um atlas de 10 glifos |
| Rodapé de 36 bytes; variante de byte mágico `0x02`; `0x86` = 64 B; pré-visualização embutida `0x28` | §11.4, §11.11 | ✅ re-verificado |
| Rótulos de ids de saúde/clima calibrados contra o menu de slots do app complementar | §16 | ⚠️ adotado como melhor leitura; conflitos sinalizados |
| UUID do serviço shell é `77d4e67c-…`, e escopo de `optionalServices` do Web Bluetooth | §1 | ⚠️ relato de unidade única, não re-verificado aqui |
| Número compartilhando id com anel renderiza uma porcentagem | §11.15 | 🟡 relatado, **não** verificado aqui |
| Catálogo de id → nome de mostradores de estoque | Apêndice A | ✅ adotado como está |
| Finalidade | 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 (min. 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/reply | INIT2 req/reply | CHUNK req/write | FINISH ack1/ack2 |
|---|
| Mostrador (foto) | 8052/0052 | 9063/A063 | A064/9064 | A065/9065 |
| Mostrador (estruturado/alternância) | 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 (epoch s) |
| 4 | 4 | passos |
| 8 | 4 | distância (m) |
| 12 | 4 | calorias |
| 16 | 16 | reservado (observado 0) |
| Offset | Tamanho | Campo |
|---|
| 0 | 4 | início_da_sessão (epoch, UTC) |
| 4 | 4 | despertar (epoch, UTC) |
| 8 | 2 | total_profundo_s |
| 10 | 2 | total_leve_s |
| 12 | 2 | total_rem_s |
| 14 | 2 | total_acordado_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 = nome(32) ‖ telefone(25). A UI do relógio mostra até 20.0063 0001) ✅ — corrige o Gadgetbridge (que colocava o rótulo no final,
preenchido com 0xff — errado). 40 bytes por alarme, big-endian:
segundosDoDia(i32) ‖ índice(u8) ‖ habilitado(u8) ‖ máscara-de-bits-de-repetição(u8) ‖ flag(u8) ‖ rótulo[32] UTF-8.
O rótulo está no offset 8 e aparece no relógio. repetição = máscara de bits dos dias da semana (0 = uma vez);
flag é ⚠️ [incerto] (marcador de uma vez?). Exemplo (13:30, idx 2): 0000bdd8 02 01 15 00 "Alarme…".005E 0001) ✅ — o app oficial e a implementação de referência usam o
DailyTargetBean v1 de 10 bytes, big-endian: passos(u32 BE) ‖ distância_m(u32 BE) ‖ calorias_kcal(u16 BE). (Esta é a forma do Gadgetbridge; relatos anteriores de que o relógio "ignorava"
eram 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 mais longa de 29 bytes (adiciona sleep_min/exercise_min/stand_h + 6 flags de habilitação, todos u32
BE após um prefixo flag(u16 LE), com faixas impostas: 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 app; prefira a
forma de 10 bytes a menos que precise dos alvos extras.0060/0061 0001) ✅: 11 bytes:
habilitado(1) ‖ limite_min(u16 LE) ‖ dndInício(u32 LE) ‖ dndFim(u32 LE). Observe que a "janela ativa
08:00–22:00" mostrada na UI é um padrão fixo do firmware e não é transportada no payload.00DC 0001) ✅: contagem(1) = 36 slots ‖ activityTypeCode[36] (códigos ativos depois 00
de preenchimento). Seleciona quais esportes aparecem no menu de treino do relógio.009B 0001) ✅: byte tipo — 01 = FC 24/7, 02 = SpO₂,
04 = estresse (medido a cada 30 min).FFFF 9059) ✅: desabilitado = 00; habilitado =
01 ‖ fcBaixa ‖ fcAlta ‖ fcAltaEsporte ‖ spo2Baixa ‖ 00 00 00 00 (um limite 0/255 = "sem limite").FFFF 9071) ✅: 01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ período(1) ‖ cicloPeríodo(1) ‖ dataInícioCiclo(u32) ‖ marcarInício(u32) ‖ marcarFim(u32) (capturado: período=5, cicloPeríodo=0x1c=28).FFFF 9073) ✅: TLV — contagem(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 ‖ contagem ‖ cityId(2 BE)…);
o relógio mapeia ids de uma tabela interna. Config de DST FFFF 9083 =
contagem ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ início(u32 LE) ‖ fim(u32 LE)]….FFFF 905C, 131 B) ✅: estado(1: 0=nenhum/1=pausado/2=tocando) ‖ volume(1) ‖ volumeMáx(1) ‖ faixa(64) ‖ artista(64). O relógio também envia MUSIC_BUTTON (A05D) de volta.FFFF 906B, 199 B) ✅ — use este: 7×9 bytes de dias + 24×2 bytes de horas +
cidade(32) + 7×8 bytes de 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 — ver §13.)005D 0001) ✅: payload 0x01 → o 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): ver §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 com anti-aliasing (glifos, ponteiros, ícones) |
| 13 (0x0d) | 0.5 | máscara alpha de 4 bits; o firmware tinge em tempo de execução | atlas de glifos de dígitos |
| 24 (0x18) | 4 | RGBA8888 | camadas de cor total (incl. o sempre ativo aodImage) |
| 1 | — | JPEG/JFIF (ff d8 ff), extraia com qualquer decodificador | quadros de animação raros |
0a0x61NotEnvelope0aChildOverflow:61 0a 00−18/−160a.bin0x68
empilhados no mesmo (x,y), cada um desenhado apenas quando uma condição de visibilidade corresponde — isso
estava certo. Mas a lista de métricas por slot (o 0x1c/0x6a/0x48/0x24/0x19/0x76 do 275) é exatamente a
lista de métricas, não "ids de opção/estilo"; o índice ativo padrão é um byte no arquivo; e o
byte 0x79/0x7a não é um "byte de instância" mas o id no qual os alternados são chaveados
(0x79 + slotIndex). Uma pré-visualização estática pode reproduzir o padrão do arquivo. Veja §11.13.(446,0), visto em 275/302/325/365/375)
são slots que o firmware não desenha na visualização padrão — seu valor nem cabe antes da
borda do canvas. Trate como ocultos na pré-visualização.0x220x22aod0x22@69,2090x60 img_number independente (cnt=10) — fonte em −5, deslocamento direto em um. ✅ Mesmo deslocamento em um
da §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 renderizava no local do ponteiro com uma fonte falsa. 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 não-zero (o guarda 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 temp 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.| tag | função | contêiner? | corpo |
|---|
0x20 | raiz da cena (wrapper de corpo, não um drawable) | ✅ | filhos |
0x21 | tela normal | ✅ | filhos |
0x22 | tela AOD (§11.9) | ✅ | filhos |
0x28 | miniatura de pré-visualização de catálogo embutida | ✅ | um filho 0x08 |
0x68 | grupo / contêiner de auto-layout | ✅ | quadro 0x48 + filhos |
0x30 | imagem estática, ou escolha-por-valor de N imagens | ✅ | 0x01 (+0x02) |
0x60 | leitura numérica ao vivo (tira de dígitos) | ✅ | 0x01 + 0x40 (+0x02) |
0x70 | ponteiro rotativo | ✅ | 0x01 + pivô 0x05 |
0x80 | anel de progresso, procedural | ✅ | 0x01 + 0x5a (§11.15) |
0x81 | anel de progresso, recortado por imagem | ✅ | 0x01 + 0x5b (§11.15) |
0x85 | slot de complicação atribuível pelo usuário | ✅ | 0x01 + 0x5f (§11.13) |
0x01 | struct — geometria + atributos (abaixo) | — | x,y,meta[14] + cauda de ref |
0x02 | condição de visibilidade (§11.12) | — | lista de condições |
0x05 | pivô — flag u8, pivotX u16, pivotY u16 | — | 5 B |
0x08 | pvStruct — prefix[5] + cauda de ref, sem x/y (apenas pré-visualização) | — | — |
0x40 | contagem de dígitos / flag de zero à esquerda (§11.10) | — | 1 B |
0x48 | quadro — x,y,w,h,gap,align linha/coluna de auto-layout | — | — |
0x5a / 0x5b | especificação de arco para 0x80 / 0x81 (§11.15) | — | 19 B / 17 B |
0x5f | lista de métricas de slot para 0x85 (§11.13) | — | — |
0x86 | nó de nome de exibição, sempre exatamente 64 bytes, terminado em NUL, não desenhado | — | 64 B |
| mostrador | slot | contagem | activeIdx | ids de métrica | → ativo |
|---|
| 275 SlopeTime | 0 | 6 | 0 | 1c 6a 48 24 19 76 | 0x1c calorias |
| 275 SlopeTime | 1 | 6 | 4 | 1c 6a 48 24 19 76 | 0x19 passos |
| 368 Function | 0 | 8 | 0 | 5f 1c 19 48 24 76 1a 8b | 0x5f temperatura |
| 368 Function | 1 | 8 | 6 | 5f 1c 19 48 24 76 1a 8b | 0x1a frequência cardíaca |
| 273 Activity Mood | 0 | 4 | 0 | 1c 24 48 6a | 0x1c calorias |
| 304 Elaborate 2 | 0/1 | 4 | 0 | 1c 48 6a 24 / 24 1c 6a 48 | 0x1c / 0x24 |